From 0219fda95f973da00ac5e127397e4d3263e2d3c0 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Mon, 13 Apr 2026 10:24:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D0=B0=D1=81=D1=88=D1=82=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5,=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4,=20=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D1=8B=20=D0=B8=20=D1=82=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B8=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлена ось времени: старые данные слева, новые справа - Подключён chartjs-plugin-zoom (колёсико, drag, pan) - Переделаны кнопки периода: 1ч/6ч/24ч/7д/30д (по умолчанию 6ч) - Добавлен cmdline в процессы тултипа (показывает полный путь) - Улучшена логика алертов: нет спама, resolved уведомления - Исправлено сохранение порогов (приведение типов) - Исправлена страница алертов (Twig syntax: ends_with -> matches) - Дашборд: цвета прогресс-баров по реальным порогам сервера Co-authored-by: Qwen-Coder --- public/chartjs-plugin-zoom.min.js | 7 ++ src/Controllers/Api/MetricsController.php | 99 ++++++++++++++++++++-- src/Controllers/DashboardController.php | 10 ++- src/Controllers/ServerDetailController.php | 58 ++++++++++++- src/Models/Server.php | 38 +++++++++ src/Services/NotificationService.php | 28 ++++-- templates/alerts/index.twig | 2 +- templates/dashboard.twig | 46 +++++++++- templates/servers/detail.twig | 84 +++++++++++------- 9 files changed, 313 insertions(+), 59 deletions(-) create mode 100644 public/chartjs-plugin-zoom.min.js diff --git a/public/chartjs-plugin-zoom.min.js b/public/chartjs-plugin-zoom.min.js new file mode 100644 index 0000000..a6c5577 --- /dev/null +++ b/public/chartjs-plugin-zoom.min.js @@ -0,0 +1,7 @@ +/*! +* chartjs-plugin-zoom v2.0.1 +* undefined + * (c) 2016-2023 chartjs-plugin-zoom Contributors + * Released under the MIT License + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("chart.js"),require("hammerjs"),require("chart.js/helpers")):"function"==typeof define&&define.amd?define(["chart.js","hammerjs","chart.js/helpers"],t):(e="undefined"!=typeof globalThis?globalThis:e||self).ChartZoom=t(e.Chart,e.Hammer,e.Chart.helpers)}(this,(function(e,t,n){"use strict";function o(e){return e&&"object"==typeof e&&"default"in e?e:{default:e}}var a=o(t);const i=e=>e&&e.enabled&&e.modifierKey,c=(e,t)=>e&&t[e+"Key"],r=(e,t)=>e&&!t[e+"Key"];function s(e,t,n){return void 0===e||("string"==typeof e?-1!==e.indexOf(t):"function"==typeof e&&-1!==e({chart:n}).indexOf(t))}function l(e,t){return"function"==typeof e&&(e=e({chart:t})),"string"==typeof e?{x:-1!==e.indexOf("x"),y:-1!==e.indexOf("y")}:{x:!1,y:!1}}function u(e,t,o){const{mode:a="xy",scaleMode:i,overScaleMode:c}=e||{},r=function({x:e,y:t},n){const o=n.scales,a=Object.keys(o);for(let n=0;n=i.top&&t<=i.bottom&&e>=i.left&&e<=i.right)return i}return null}(t,o),s=l(a,o),u=l(i,o);if(c){const e=l(c,o);for(const t of["x","y"])e[t]&&(u[t]=s[t],s[t]=!1)}if(r&&u[r.axis])return[r];const m=[];return n.each(o.scales,(function(e){s[e.axis]&&m.push(e)})),m}const m=new WeakMap;function d(e){let t=m.get(e);return t||(t={originalScaleLimits:{},updatedScaleLimits:{},handlers:{},panDelta:{}},m.set(e,t)),t}function f(e,t,n){const o=e.max-e.min,a=o*(t-1),i=e.isHorizontal()?n.x:n.y,c=Math.max(0,Math.min(1,(e.getValueForPixel(i)-e.min)/o||0));return{min:a*c,max:a*(1-c)}}function p(e,t,o,a,i){let c=o[a];if("original"===c){const o=e.originalScaleLimits[t.id][a];c=n.valueOrDefault(o.options,o.scale)}return n.valueOrDefault(c,i)}function h(e,{min:t,max:n},o,a=!1){const i=d(e.chart),{id:c,axis:r,options:s}=e,l=o&&(o[c]||o[r])||{},{minRange:u=0}=l,m=p(i,e,l,"min",-1/0),f=p(i,e,l,"max",1/0),h=a?Math.max(n-t,u):e.max-e.min,x=(h-n+t)/2;return n+=x,(t-=x)f&&(n=f,t=Math.max(f-h,m)),s.min=t,s.max=n,i.updatedScaleLimits[e.id]={min:t,max:n},e.parse(t)!==e.min||e.parse(n)!==e.max}const x=e=>0===e||isNaN(e)?0:e<0?Math.min(Math.round(e),-1):Math.max(Math.round(e),1);const g={second:500,minute:3e4,hour:18e5,day:432e5,week:3024e5,month:1296e6,quarter:5184e6,year:157248e5};function y(e,t,n,o=!1){const{min:a,max:i,options:c}=e,r=c.time&&c.time.round,s=g[r]||0,l=e.getValueForPixel(e.getPixelForValue(a+s)-t),u=e.getValueForPixel(e.getPixelForValue(i+s)-t),{min:m=-1/0,max:d=1/0}=o&&n&&n[e.axis]||{};return!!(isNaN(l)||isNaN(u)||ld)||h(e,{min:l,max:u},n,o)}function b(e,t,n){return y(e,t,n,!0)}const v={category:function(e,t,n,o){const a=f(e,t,n);return e.min===e.max&&t<1&&function(e){const t=e.getLabels().length-1;e.min>0&&(e.min-=1),e.maxr&&(a=Math.max(0,a-s),i=1===c?a:a+c,l=0===a),h(e,{min:a,max:i},n)||l},default:y,logarithmic:b,timeseries:b};function M(e,t){n.each(e,((n,o)=>{t[o]||delete e[o]}))}function k(e,t){const{scales:o}=e,{originalScaleLimits:a,updatedScaleLimits:i}=t;return n.each(o,(function(e){(function(e,t,n){const{id:o,options:{min:a,max:i}}=e;if(!t[o]||!n[o])return!0;const c=n[o];return c.min!==a||c.max!==i})(e,a,i)&&(a[e.id]={min:{scale:e.min,options:e.options.min},max:{scale:e.max,options:e.options.max}})})),M(a,o),M(i,o),a}function S(e,t,o,a){const i=v[e.type]||v.default;n.callback(i,[e,t,o,a])}function P(e,t,o,a,i){const c=w[e.type]||w.default;n.callback(c,[e,t,o,a,i])}function D(e){const t=e.chartArea;return{x:(t.left+t.right)/2,y:(t.top+t.bottom)/2}}function j(e,t,o="none"){const{x:a=1,y:i=1,focalPoint:c=D(e)}="number"==typeof t?{x:t,y:t}:t,r=d(e),{options:{limits:s,zoom:l}}=r;k(e,r);const m=1!==a,f=1!==i,p=u(l,c,e);n.each(p||e.scales,(function(e){e.isHorizontal()&&m?S(e,a,c,s):!e.isHorizontal()&&f&&S(e,i,c,s)})),e.update(o),n.callback(l.onZoom,[{chart:e}])}function O(e,t,o,a="none"){const i=d(e),{options:{limits:c,zoom:r}}=i,{mode:l="xy"}=r;k(e,i);const u=s(l,"x",e),m=s(l,"y",e);n.each(e.scales,(function(e){e.isHorizontal()&&u?P(e,t.x,o.x,c):!e.isHorizontal()&&m&&P(e,t.y,o.y,c)})),e.update(a),n.callback(r.onZoom,[{chart:e}])}function C(e){const t=d(e);let o=1,a=1;return n.each(e.scales,(function(e){const i=function(e,t){const o=e.originalScaleLimits[t];if(!o)return;const{min:a,max:i}=o;return n.valueOrDefault(i.options,i.scale)-n.valueOrDefault(a.options,a.scale)}(t,e.id);if(i){const t=Math.round(i/(e.max-e.min)*100)/100;o=Math.min(o,t),a=Math.max(a,t)}})),o<1?o:a}function R(e,t,o,a){const{panDelta:i}=a,c=i[e.id]||0;n.sign(c)===n.sign(t)&&(t+=c);const r=z[e.type]||z.default;n.callback(r,[e,t,o])?i[e.id]=0:i[e.id]=t}function Z(e,t,o,a="none"){const{x:i=0,y:c=0}="number"==typeof t?{x:t,y:t}:t,r=d(e),{options:{pan:s,limits:l}}=r,{onPan:u}=s||{};k(e,r);const m=0!==i,f=0!==c;n.each(o||e.scales,(function(e){e.isHorizontal()&&m?R(e,i,l,r):!e.isHorizontal()&&f&&R(e,c,l,r)})),e.update(a),n.callback(u,[{chart:e}])}function T(e){const t=d(e);k(e,t);const n={};for(const o of Object.keys(e.scales)){const{min:e,max:a}=t.originalScaleLimits[o]||{min:{},max:{}};n[o]={min:e.scale,max:a.scale}}return n}function L(e,t){const{handlers:n}=d(e),o=n[t];o&&o.target&&(o.target.removeEventListener(t,o),delete n[t])}function E(e,t,n,o){const{handlers:a,options:i}=d(e),c=a[n];c&&c.target===t||(L(e,n),a[n]=t=>o(e,t,i),a[n].target=t,t.addEventListener(n,a[n]))}function F(e,t){const n=d(e);n.dragStart&&(n.dragging=!0,n.dragEnd=t,e.update("none"))}function H(e,t){const n=d(e);n.dragStart&&"Escape"===t.key&&(L(e,"keydown"),n.dragging=!1,n.dragStart=n.dragEnd=null,e.update("none"))}function Y(e,t,o){const{onZoomStart:a,onZoomRejected:i}=o;if(a){const o=n.getRelativePosition(t,e);if(!1===n.callback(a,[{chart:e,event:t,point:o}]))return n.callback(i,[{chart:e,event:t}]),!1}}function V(e,t){const o=d(e),{pan:a,zoom:s={}}=o.options;if(0!==t.button||c(i(a),t)||r(i(s.drag),t))return n.callback(s.onZoomRejected,[{chart:e,event:t}]);!1!==Y(e,t,s)&&(o.dragStart=t,E(e,e.canvas,"mousemove",F),E(e,window.document,"keydown",H))}function K(e,t,o,a){const i=s(t,"x",e),c=s(t,"y",e);let{top:r,left:l,right:u,bottom:m,width:d,height:f}=e.chartArea;const p=n.getRelativePosition(o,e),h=n.getRelativePosition(a,e);i&&(l=Math.min(p.x,h.x),u=Math.max(p.x,h.x)),c&&(r=Math.min(p.y,h.y),m=Math.max(p.y,h.y));const x=u-l,g=m-r;return{left:l,top:r,right:u,bottom:m,width:x,height:g,zoomX:i&&x?1+(d-x)/d:1,zoomY:c&&g?1+(f-g)/f:1}}function N(e,t){const o=d(e);if(!o.dragStart)return;L(e,"mousemove");const{mode:a,onZoomComplete:i,drag:{threshold:c=0}}=o.options.zoom,r=K(e,a,o.dragStart,t),l=s(a,"x",e)?r.width:0,u=s(a,"y",e)?r.height:0,m=Math.sqrt(l*l+u*u);if(o.dragStart=o.dragEnd=null,m<=c)return o.dragging=!1,void e.update("none");O(e,{x:r.left,y:r.top},{x:r.right,y:r.bottom},"zoom"),setTimeout((()=>o.dragging=!1),500),n.callback(i,[{chart:e}])}function X(e,t){const{handlers:{onZoomComplete:o},options:{zoom:a}}=d(e);if(!function(e,t,o){if(r(i(o.wheel),t))n.callback(o.onZoomRejected,[{chart:e,event:t}]);else if(!1!==Y(e,t,o)&&(t.cancelable&&t.preventDefault(),void 0!==t.deltaY))return!0}(e,t,a))return;const c=t.target.getBoundingClientRect(),s=1+(t.deltaY>=0?-a.wheel.speed:a.wheel.speed);j(e,{x:s,y:s,focalPoint:{x:t.clientX-c.left,y:t.clientY-c.top}}),o&&o()}function q(e,t,o,a){o&&(d(e).handlers[t]=function(e,t){let n;return function(){return clearTimeout(n),n=setTimeout(e,t),t}}((()=>n.callback(o,[{chart:e}])),a))}function W(e,t){return function(o,a){const{pan:s,zoom:l={}}=t.options;if(!s||!s.enabled)return!1;const u=a&&a.srcEvent;return!u||(!(!t.panning&&"mouse"===a.pointerType&&(r(i(s),u)||c(i(l.drag),u)))||(n.callback(s.onPanRejected,[{chart:e,event:a}]),!1))}}function B(e,t,n){if(t.scale){const{center:o,pointers:a}=n,i=1/t.scale*n.scale,c=n.target.getBoundingClientRect(),r=function(e,t){const n=Math.abs(e.clientX-t.clientX),o=Math.abs(e.clientY-t.clientY),a=n/o;let i,c;return a>.3&&a<1.7?i=c=!0:n>o?i=!0:c=!0,{x:i,y:c}}(a[0],a[1]),l=t.options.zoom.mode;j(e,{x:r.x&&s(l,"x",e)?i:1,y:r.y&&s(l,"y",e)?i:1,focalPoint:{x:o.x-c.left,y:o.y-c.top}}),t.scale=n.scale}}function A(e,t,n){const o=t.delta;o&&(t.panning=!0,Z(e,{x:n.deltaX-o.x,y:n.deltaY-o.y},t.panScales),t.delta={x:n.deltaX,y:n.deltaY})}const I=new WeakMap;function U(e,t){const o=d(e),i=e.canvas,{pan:c,zoom:r}=t,s=new a.default.Manager(i);r&&r.pinch.enabled&&(s.add(new a.default.Pinch),s.on("pinchstart",(()=>function(e,t){t.options.zoom.pinch.enabled&&(t.scale=1)}(0,o))),s.on("pinch",(t=>B(e,o,t))),s.on("pinchend",(t=>function(e,t,o){t.scale&&(B(e,t,o),t.scale=null,n.callback(t.options.zoom.onZoomComplete,[{chart:e}]))}(e,o,t)))),c&&c.enabled&&(s.add(new a.default.Pan({threshold:c.threshold,enable:W(e,o)})),s.on("panstart",(t=>function(e,t,o){const{enabled:a,onPanStart:i,onPanRejected:c}=t.options.pan;if(!a)return;const r=o.target.getBoundingClientRect(),s={x:o.center.x-r.left,y:o.center.y-r.top};if(!1===n.callback(i,[{chart:e,event:o,point:s}]))return n.callback(c,[{chart:e,event:o}]);t.panScales=u(t.options.pan,s,e),t.delta={x:0,y:0},clearTimeout(t.panEndTimeout),A(e,t,o)}(e,o,t))),s.on("panmove",(t=>A(e,o,t))),s.on("panend",(()=>function(e,t){t.delta=null,t.panning&&(t.panEndTimeout=setTimeout((()=>t.panning=!1),500),n.callback(t.options.pan.onPanComplete,[{chart:e}]))}(e,o)))),I.set(e,s)}function G(e,t,n){const o=n.zoom.drag,{dragStart:a,dragEnd:i}=d(e);if(o.drawTime!==t||!i)return;const{left:c,top:r,width:s,height:l}=K(e,n.zoom.mode,a,i),u=e.ctx;u.save(),u.beginPath(),u.fillStyle=o.backgroundColor||"rgba(225,225,225,0.3)",u.fillRect(c,r,s,l),o.borderWidth>0&&(u.lineWidth=o.borderWidth,u.strokeStyle=o.borderColor||"rgba(225,225,225)",u.strokeRect(c,r,s,l)),u.restore()}var J={id:"zoom",version:"2.0.1",defaults:{pan:{enabled:!1,mode:"xy",threshold:10,modifierKey:null},zoom:{wheel:{enabled:!1,speed:.1,modifierKey:null},drag:{enabled:!1,drawTime:"beforeDatasetsDraw",modifierKey:null},pinch:{enabled:!1},mode:"xy"}},start:function(e,t,o){d(e).options=o,Object.prototype.hasOwnProperty.call(o.zoom,"enabled")&&console.warn("The option `zoom.enabled` is no longer supported. Please use `zoom.wheel.enabled`, `zoom.drag.enabled`, or `zoom.pinch.enabled`."),(Object.prototype.hasOwnProperty.call(o.zoom,"overScaleMode")||Object.prototype.hasOwnProperty.call(o.pan,"overScaleMode"))&&console.warn("The option `overScaleMode` is deprecated. Please use `scaleMode` instead (and update `mode` as desired)."),a.default&&U(e,o),e.pan=(t,n,o)=>Z(e,t,n,o),e.zoom=(t,n)=>j(e,t,n),e.zoomRect=(t,n,o)=>O(e,t,n,o),e.zoomScale=(t,n,o)=>function(e,t,n,o="none"){k(e,d(e)),h(e.scales[t],n,void 0,!0),e.update(o)}(e,t,n,o),e.resetZoom=t=>function(e,t="default"){const o=d(e),a=k(e,o);n.each(e.scales,(function(e){const t=e.options;a[e.id]?(t.min=a[e.id].min.options,t.max=a[e.id].max.options):(delete t.min,delete t.max)})),e.update(t),n.callback(o.options.zoom.onZoomComplete,[{chart:e}])}(e,t),e.getZoomLevel=()=>C(e),e.getInitialScaleBounds=()=>T(e),e.isZoomedOrPanned=()=>function(e){const t=T(e);for(const n of Object.keys(e.scales)){const{min:o,max:a}=t[n];if(void 0!==o&&e.scales[n].min!==o)return!0;if(void 0!==a&&e.scales[n].max!==a)return!0}return!1}(e)},beforeEvent(e){const t=d(e);if(t.panning||t.dragging)return!1},beforeUpdate:function(e,t,n){d(e).options=n,function(e,t){const n=e.canvas,{wheel:o,drag:a,onZoomComplete:i}=t.zoom;o.enabled?(E(e,n,"wheel",X),q(e,"onZoomComplete",i,250)):L(e,"wheel"),a.enabled?(E(e,n,"mousedown",V),E(e,n.ownerDocument,"mouseup",N)):(L(e,"mousedown"),L(e,"mousemove"),L(e,"mouseup"),L(e,"keydown"))}(e,n)},beforeDatasetsDraw(e,t,n){G(e,"beforeDatasetsDraw",n)},afterDatasetsDraw(e,t,n){G(e,"afterDatasetsDraw",n)},beforeDraw(e,t,n){G(e,"beforeDraw",n)},afterDraw(e,t,n){G(e,"afterDraw",n)},stop:function(e){!function(e){L(e,"mousedown"),L(e,"mousemove"),L(e,"mouseup"),L(e,"wheel"),L(e,"click"),L(e,"keydown")}(e),a.default&&function(e){const t=I.get(e);t&&(t.remove("pinchstart"),t.remove("pinch"),t.remove("pinchend"),t.remove("panstart"),t.remove("pan"),t.remove("panend"),t.destroy(),I.delete(e))}(e),function(e){m.delete(e)}(e)},panFunctions:z,zoomFunctions:v,zoomRectFunctions:w};return e.Chart.register(J),J})); diff --git a/src/Controllers/Api/MetricsController.php b/src/Controllers/Api/MetricsController.php index ad31381..27395d7 100755 --- a/src/Controllers/Api/MetricsController.php +++ b/src/Controllers/Api/MetricsController.php @@ -176,25 +176,106 @@ class MetricsController extends Model } if ($severity) { - // Создаем алерт + // Проверяем есть ли уже неразрешённый алерт для этой метрики $stmt = $this->pdo->prepare(" - INSERT INTO alerts (server_id, metric_name, value, severity) - VALUES (:server_id, :metric_name, :value, :severity) + SELECT id, severity FROM alerts + WHERE server_id = :server_id AND metric_name = :metric_name AND resolved = FALSE + ORDER BY created_at DESC LIMIT 1 "); $stmt->execute([ ':server_id' => $serverId, - ':metric_name' => $metricName, - ':value' => $value, - ':severity' => $severity + ':metric_name' => $metricName ]); + $existingAlert = $stmt->fetch(); + + if ($existingAlert) { + // Алерт уже есть — обновляем значение но НЕ отправляем уведомление + $stmt = $this->pdo->prepare(" + UPDATE alerts SET value = :value WHERE id = :id + "); + $stmt->execute([ + ':value' => $value, + ':id' => $existingAlert['id'] + ]); + + // Если серьёзность повысилась (warning -> critical) — отправляем + if ($severity === 'critical' && $existingAlert['severity'] === 'warning') { + $stmt = $this->pdo->prepare(" + UPDATE alerts SET severity = :severity WHERE id = :id + "); + $stmt->execute([ + ':severity' => $severity, + ':id' => $existingAlert['id'] + ]); + $this->notificationService->sendAlertNotification( + $serverName, + $metricName, + $value, + $severity, + $threshold + ); + } + } else { + // Нового алерта нет — создаём и отправляем уведомление + $stmt = $this->pdo->prepare(" + INSERT INTO alerts (server_id, metric_name, value, severity) + VALUES (:server_id, :metric_name, :value, :severity) + "); + $stmt->execute([ + ':server_id' => $serverId, + ':metric_name' => $metricName, + ':value' => $value, + ':severity' => $severity + ]); + + $this->notificationService->sendAlertNotification( + $serverName, + $metricName, + $value, + $severity, + $threshold + ); + } + } + } + + // Всегда проверяем resolved — даже если пороги не настроены или удалены + // Если есть неразрешённый алерт а значение сейчас в норме — разрешаем + $stmt = $this->pdo->prepare(" + SELECT id FROM alerts + WHERE server_id = :server_id AND metric_name = :metric_name AND resolved = FALSE + ORDER BY created_at DESC LIMIT 1 + "); + $stmt->execute([ + ':server_id' => $serverId, + ':metric_name' => $metricName + ]); + $existingAlert = $stmt->fetch(); + + if ($existingAlert) { + // Проверяем действительно ли значение в норме + // (если пороги есть — проверяем по ним, если нет — считаем что в норме) + $isNormal = true; + if ($thresholds) { + $w = $thresholds['warning_threshold']; + $c = $thresholds['critical_threshold']; + if (($c && $value >= $c) || ($w && $value >= $w)) { + $isNormal = false; + } + } + + if ($isNormal) { + $stmt = $this->pdo->prepare(" + UPDATE alerts SET resolved = TRUE, resolved_at = NOW() WHERE id = :id + "); + $stmt->execute([':id' => $existingAlert['id']]); - // Отправляем уведомление $this->notificationService->sendAlertNotification( $serverName, $metricName, $value, - $severity, - $threshold + 'resolved', + 'Порог более не превышен' ); } } diff --git a/src/Controllers/DashboardController.php b/src/Controllers/DashboardController.php index 78df41c..66b1621 100755 --- a/src/Controllers/DashboardController.php +++ b/src/Controllers/DashboardController.php @@ -25,7 +25,14 @@ class DashboardController // Получаем список серверов со статусами для цветных карточек $servers = $this->serverModel->getServersWithStatus(); - + + // Загружаем пороги для каждого сервера + foreach ($servers as &$server) { + $t = $this->serverModel->getThresholds($server['id']); + $server['thresholds'] = $t; + file_put_contents('/tmp/thresholds_debug.log', "Server {$server['id']}: " . json_encode($t) . "\n", FILE_APPEND); + } + unset($server); $templateData = [ 'title' => 'Дашборд мониторинга', @@ -33,7 +40,6 @@ class DashboardController 'servers' => $servers ]; - file_put_contents("/tmp/dashboard_debug.log", json_encode($servers) . "\n", FILE_APPEND); return $this->twig->render($response, 'dashboard.twig', $templateData); } } diff --git a/src/Controllers/ServerDetailController.php b/src/Controllers/ServerDetailController.php index 40b25a5..4ea2f44 100755 --- a/src/Controllers/ServerDetailController.php +++ b/src/Controllers/ServerDetailController.php @@ -37,21 +37,69 @@ class ServerDetailController extends Model return $response->withHeader('Location', '/servers')->withStatus(302); } - // Получаем даты начала и окончания + // Получаем параметры $queryParams = $request->getQueryParams(); $startDate = $queryParams['start'] ?? null; $endDate = $queryParams['end'] ?? null; + $period = $queryParams['period'] ?? '24h'; + $zoom = $queryParams['zoom'] ?? null; - // Если даты не указаны, используем последние 24 часа по умолчанию + // Если даты не указаны, вычисляем по period if (!$startDate || !$endDate) { $endDate = new DateTime(); $startDate = clone $endDate; - $startDate->modify('-24 hours'); + + switch ($period) { + case '1h': + $startDate->modify('-1 hour'); + break; + case '6h': + $startDate->modify('-6 hours'); + break; + case '7d': + $startDate->modify('-7 days'); + break; + case '30d': + $startDate->modify('-30 days'); + break; + case '24h': + default: + $startDate->modify('-24 hours'); + break; + } } else { $startDate = new DateTime($startDate); $endDate = new DateTime($endDate); } + // Применяем zoom — ограничиваем end по zoom-периоду + if ($zoom && $zoom !== 'auto') { + $zoomEnd = new DateTime(); + $zoomStart = clone $zoomEnd; + switch ($zoom) { + case '1h': + $zoomStart->modify('-1 hour'); + break; + case '6h': + $zoomStart->modify('-6 hours'); + break; + case '24h': + $zoomStart->modify('-24 hours'); + break; + case '7d': + $zoomStart->modify('-7 days'); + break; + case '30d': + $zoomStart->modify('-30 days'); + break; + } + // Zoom не может выйти за рамки выбранного периода + if ($zoomStart < $startDate) $zoomStart = clone $startDate; + if ($zoomEnd > $endDate) $zoomEnd = clone $endDate; + $startDate = $zoomStart; + $endDate = $zoomEnd; + } + // Валидация: end > start if ($endDate <= $startDate) { $endDate = clone $startDate; @@ -185,7 +233,9 @@ class ServerDetailController extends Model 'startDate' => $startDate->format('Y-m-d\T H:i'), 'endDate' => $endDate->format('Y-m-d\T H:i'), 'aggregation' => $aggConfig, - 'totalMinutes' => $totalMinutes + 'totalMinutes' => $totalMinutes, + 'period' => $period, + 'zoom' => $zoom ]; return $this->twig->render($response, 'servers/detail.twig', $templateData); diff --git a/src/Models/Server.php b/src/Models/Server.php index d1310a3..6d7c650 100755 --- a/src/Models/Server.php +++ b/src/Models/Server.php @@ -124,6 +124,44 @@ class Server $server['active_alerts'] = (int)$activeAlerts; } + // Загружаем пороги для каждого сервера + foreach ($servers as &$server) { + $stmt = $this->db->prepare("SELECT mn.name, mt.warning_threshold, mt.critical_threshold FROM metric_thresholds mt JOIN metric_names mn ON mt.metric_name_id = mn.id WHERE mt.server_id = :server_id"); + $stmt->execute([':server_id' => $server['id']]); + $thresholds = $stmt->fetchAll(); + $server['thresholds'] = []; + foreach ($thresholds as $t) { + $server['thresholds'][$t['name']] = [ + 'warning' => (float)$t['warning_threshold'], + 'critical' => (float)$t['critical_threshold'] + ]; + } + } + unset($server); + return $servers; } + + public function getThresholds($serverId) + { + error_log("getThresholds called for server $serverId"); + $stmt = $this->db->prepare(" + SELECT mn.name, mt.warning_threshold, mt.critical_threshold + FROM metric_thresholds mt + JOIN metric_names mn ON mt.metric_name_id = mn.id + WHERE mt.server_id = :server_id + "); + $stmt->execute([':server_id' => $serverId]); + $thresholds = $stmt->fetchAll(); + error_log("getThresholds result for server $serverId: " . json_encode($thresholds)); + $result = []; + foreach ($thresholds as $t) { + $result[$t['name']] = [ + 'warning' => (float)$t['warning_threshold'], + 'critical' => (float)$t['critical_threshold'] + ]; + } + error_log("getThresholds returning for server $serverId: " . json_encode($result)); + return $result; + } } diff --git a/src/Services/NotificationService.php b/src/Services/NotificationService.php index 2351264..682b368 100755 --- a/src/Services/NotificationService.php +++ b/src/Services/NotificationService.php @@ -28,14 +28,26 @@ class NotificationService */ public function sendAlertNotification($serverName, $metricName, $value, $severity, $threshold) { - $severityText = $severity === 'critical' ? 'КРИТИЧЕСКИЙ' : 'ПРЕДУПРЕЖДЕНИЕ'; - $subject = "🚨 {$severityText}: Превышение порога {$metricName}"; - $message = "Сервер: {$serverName}\n"; - $message .= "Метрика: {$metricName}\n"; - $message .= "Значение: {$value}\n"; - $message .= "Порог: {$threshold}\n"; - $message .= "Время: " . date('d.m.Y H:i:s') . "\n"; - $message .= "Серьёзность: {$severityText}"; + if ($severity === 'resolved') { + $severityText = 'ВОССТАНОВЛЕНИЕ'; + $emoji = '✅'; + $subject = "{$emoji} {$severityText}: {$metricName} в норме"; + $message = "Сервер: {$serverName}\n"; + $message .= "Метрика: {$metricName}\n"; + $message .= "Текущее значение: {$value}\n"; + $message .= "Статус: Порог более не превышен\n"; + $message .= "Время: " . date('d.m.Y H:i:s'); + } else { + $severityText = $severity === 'critical' ? 'КРИТИЧЕСКИЙ' : 'ПРЕДУПРЕЖДЕНИЕ'; + $emoji = '🚨'; + $subject = "{$emoji} {$severityText}: Превышение порога {$metricName}"; + $message = "Сервер: {$serverName}\n"; + $message .= "Метрика: {$metricName}\n"; + $message .= "Значение: {$value}\n"; + $message .= "Порог: {$threshold}\n"; + $message .= "Время: " . date('d.m.Y H:i:s') . "\n"; + $message .= "Серьёзность: {$severityText}"; + } // Отправка Email if (!empty($this->settings['email_enabled']) && !empty($this->settings['smtp_host'])) { diff --git a/templates/alerts/index.twig b/templates/alerts/index.twig index 1c904d4..932c961 100755 --- a/templates/alerts/index.twig +++ b/templates/alerts/index.twig @@ -27,7 +27,7 @@ {{ alert.server_name }} {{ alert.metric_name|replace({'_': ' ', 'load': 'загрузка', 'used': 'использование'})|title }} - {{ alert.value }}{% if alert.metric_name ends_with '_load' or alert.metric_name ends_with '_used' %}%{% endif %} + {{ alert.value }}{% if alert.metric_name matches '/_load$/' or alert.metric_name matches '/_used$/' %}%{% endif %} {% if alert.severity == 'critical' %} Критично diff --git a/templates/dashboard.twig b/templates/dashboard.twig index 8ee40a6..803c1e6 100755 --- a/templates/dashboard.twig +++ b/templates/dashboard.twig @@ -54,8 +54,11 @@ + +{% for s in servers %}{% endfor %}
{% for server in servers %} +
@@ -98,6 +101,7 @@
Статус: {{ server.status }}
+{% for s in servers %}{% endfor %}
{% if server.latest_metrics['cpu_load'] is defined %}
@@ -105,8 +109,20 @@ CPU {{ server.latest_metrics['cpu_load'].value }}{{ server.latest_metrics['cpu_load'].unit }}
+ {% set cpu_t = server.thresholds['cpu_load']|default(null) %} + {% if cpu_t and server.latest_metrics['cpu_load'].value >= cpu_t.critical %} + {% set cpu_color = 'bg-danger' %} + {% elseif cpu_t and server.latest_metrics['cpu_load'].value >= cpu_t.warning %} + {% set cpu_color = 'bg-warning' %} + {% elseif server.latest_metrics['cpu_load'].value > 80 %} + {% set cpu_color = 'bg-danger' %} + {% elseif server.latest_metrics['cpu_load'].value > 60 %} + {% set cpu_color = 'bg-warning' %} + {% else %} + {% set cpu_color = 'bg-success' %} + {% endif %}
-
@@ -119,8 +135,20 @@ RAM {{ server.latest_metrics['ram_used'].value }}{{ server.latest_metrics['ram_used'].unit }}
+ {% set ram_t = server.thresholds['ram_used']|default(null) %} + {% if ram_t and server.latest_metrics['ram_used'].value >= ram_t.critical %} + {% set ram_color = 'bg-danger' %} + {% elseif ram_t and server.latest_metrics['ram_used'].value >= ram_t.warning %} + {% set ram_color = 'bg-warning' %} + {% elseif server.latest_metrics['ram_used'].value > 80 %} + {% set ram_color = 'bg-danger' %} + {% elseif server.latest_metrics['ram_used'].value > 60 %} + {% set ram_color = 'bg-warning' %} + {% else %} + {% set ram_color = 'bg-success' %} + {% endif %}
-
@@ -133,8 +161,20 @@ Диск {{ server.latest_metrics['disk_used'].value }}{{ server.latest_metrics['disk_used'].unit }}
+ {% set disk_t = server.thresholds['disk_used']|default(null) %} + {% if disk_t and server.latest_metrics['disk_used'].value >= disk_t.critical %} + {% set disk_color = 'bg-danger' %} + {% elseif disk_t and server.latest_metrics['disk_used'].value >= disk_t.warning %} + {% set disk_color = 'bg-warning' %} + {% elseif server.latest_metrics['disk_used'].value > 80 %} + {% set disk_color = 'bg-danger' %} + {% elseif server.latest_metrics['disk_used'].value > 60 %} + {% set disk_color = 'bg-warning' %} + {% else %} + {% set disk_color = 'bg-success' %} + {% endif %}
-
diff --git a/templates/servers/detail.twig b/templates/servers/detail.twig index b0bf5e5..6135c17 100755 --- a/templates/servers/detail.twig +++ b/templates/servers/detail.twig @@ -88,46 +88,34 @@
-
+ -
-
- Масштаб: -
- - авто - - +
+ +
+ +
+ 💡 Колёсико мыши = зум, перетаскивание = выделение области, Shift+колёсико = панорама
@@ -411,6 +399,8 @@ + + {% endblock %}