feat(alerts): implement customizable alert settings for server availability and resource usage, enhancing user control over notifications
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m54s
This commit is contained in:
@@ -1,49 +1,97 @@
|
||||
/**
|
||||
* Система оповещений: агрегирует проблемы из availability и resources/stats.
|
||||
* Критерии проблем: сервер недоступен, MikroTik недоступен, высокое CPU/RAM/HDD.
|
||||
* Критерии и пороги задаются в ui-settings.alertSettings.
|
||||
*/
|
||||
|
||||
const { sendError } = require('../middleware/errorHandler');
|
||||
const { readServersFromS3 } = require('./serversRoutes');
|
||||
const { checkOneServerFast } = require('./miscRoutes');
|
||||
const { checkOneServerFast, loadUiSettingsSync } = require('./miscRoutes');
|
||||
const { getResourceStatsData } = require('./resourceStatsRoutes');
|
||||
const { getUptimeCacheData } = require('./mikrotikConfigRoutes');
|
||||
|
||||
/** Пороги для ресурсов (проценты). */
|
||||
const THRESHOLD_CPU = 85;
|
||||
const THRESHOLD_RAM = 85;
|
||||
const THRESHOLD_HDD = 90;
|
||||
/** Значения по умолчанию для настроек оповещений. */
|
||||
const DEFAULT_ALERT_SETTINGS = {
|
||||
serverOffline: { enabled: true },
|
||||
mikrotikUnreachable: { enabled: true },
|
||||
highCpu: { enabled: true, thresholdPercent: 85 },
|
||||
highRam: { enabled: true, thresholdPercent: 85 },
|
||||
highHdd: { enabled: true, thresholdPercent: 90 },
|
||||
};
|
||||
|
||||
function getAlertSettings(uiSettings) {
|
||||
const raw = uiSettings?.alertSettings && typeof uiSettings.alertSettings === 'object' ? uiSettings.alertSettings : {};
|
||||
return {
|
||||
serverOffline: { ...DEFAULT_ALERT_SETTINGS.serverOffline, ...raw.serverOffline },
|
||||
mikrotikUnreachable: { ...DEFAULT_ALERT_SETTINGS.mikrotikUnreachable, ...raw.mikrotikUnreachable },
|
||||
highCpu: { ...DEFAULT_ALERT_SETTINGS.highCpu, ...raw.highCpu },
|
||||
highRam: { ...DEFAULT_ALERT_SETTINGS.highRam, ...raw.highRam },
|
||||
highHdd: { ...DEFAULT_ALERT_SETTINGS.highHdd, ...raw.highHdd },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/alerts
|
||||
* Возвращает список активных оповещений.
|
||||
* Возвращает список активных оповещений (учёт настроек из ui-settings.alertSettings).
|
||||
*/
|
||||
async function getAlerts(req, res) {
|
||||
try {
|
||||
const uiSettings = await loadUiSettingsSync();
|
||||
const settings = getAlertSettings(uiSettings);
|
||||
|
||||
const servers = await readServersFromS3();
|
||||
const alerts = [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 1) Доступность серверов (TCP 80/443)
|
||||
const availabilityChecks = await Promise.allSettled(
|
||||
servers.map((s) => checkOneServerFast(s))
|
||||
);
|
||||
servers.forEach((server, i) => {
|
||||
const online = availabilityChecks[i].status === 'fulfilled' && availabilityChecks[i].value;
|
||||
if (!online) {
|
||||
const name = server.name || server.dns || server.ip || server.id || `Сервер #${i + 1}`;
|
||||
alerts.push({
|
||||
id: `server-offline-${server.id || server.dns || server.ip || i}`,
|
||||
type: 'server_offline',
|
||||
severity: 'critical',
|
||||
title: 'Сервер недоступен',
|
||||
description: `${name} не отвечает на TCP (80/443).`,
|
||||
entity: name,
|
||||
entityId: server.id || server.dns || server.ip,
|
||||
link: '/servers',
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
// 1) Доступность серверов: jumphost/home — по правилам Uptime Monitor (кеш), остальные — TCP 80/443
|
||||
if (settings.serverOffline.enabled) {
|
||||
const routerTypes = ['jumphost', 'home'];
|
||||
const { results: uptimeResults } = await getUptimeCacheData();
|
||||
|
||||
const nonRouterServers = servers.filter(
|
||||
(s) => !routerTypes.includes(String(s.type || '').toLowerCase())
|
||||
);
|
||||
const availabilityChecks = await Promise.allSettled(
|
||||
nonRouterServers.map((s) => checkOneServerFast(s))
|
||||
);
|
||||
|
||||
servers.forEach((server) => {
|
||||
const serverId = server.id || server.dns || server.ip;
|
||||
const name = server.name || server.dns || server.ip || server.id || 'Сервер';
|
||||
const isRouter = routerTypes.includes(String(server.type || '').toLowerCase());
|
||||
|
||||
let offline = false;
|
||||
let description = '';
|
||||
|
||||
if (isRouter) {
|
||||
const entry = uptimeResults[serverId];
|
||||
offline = entry ? entry.ok === false : false;
|
||||
description = offline
|
||||
? `${name} недоступен (по проверке Uptime Monitor).`
|
||||
: '';
|
||||
} else {
|
||||
const idx = nonRouterServers.findIndex(
|
||||
(s) => (s.id || s.dns || s.ip) === serverId
|
||||
);
|
||||
const online = idx >= 0 && availabilityChecks[idx].status === 'fulfilled' && availabilityChecks[idx].value;
|
||||
offline = !online;
|
||||
description = offline ? `${name} не отвечает на TCP (80/443).` : '';
|
||||
}
|
||||
|
||||
if (offline && description) {
|
||||
alerts.push({
|
||||
id: `server-offline-${serverId}`,
|
||||
type: 'server_offline',
|
||||
severity: 'critical',
|
||||
title: 'Сервер недоступен',
|
||||
description,
|
||||
entity: name,
|
||||
entityId: serverId,
|
||||
link: isRouter ? '/uptime-monitor' : '/servers',
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2) Ресурсы роутеров (MikroTik): ошибка доступа, высокое CPU/RAM/HDD
|
||||
let resourceData;
|
||||
@@ -54,12 +102,16 @@ async function getAlerts(req, res) {
|
||||
resourceData = { routers: [] };
|
||||
}
|
||||
|
||||
const thCpu = Math.max(1, Math.min(100, Number(settings.highCpu.thresholdPercent) || 85));
|
||||
const thRam = Math.max(1, Math.min(100, Number(settings.highRam.thresholdPercent) || 85));
|
||||
const thHdd = Math.max(1, Math.min(100, Number(settings.highHdd.thresholdPercent) || 90));
|
||||
|
||||
const routers = resourceData.routers || [];
|
||||
routers.forEach((router) => {
|
||||
const { serverId, name, error, resource } = router;
|
||||
const entityName = name || serverId;
|
||||
|
||||
if (error) {
|
||||
if (error && settings.mikrotikUnreachable.enabled) {
|
||||
alerts.push({
|
||||
id: `mikrotik-error-${serverId}`,
|
||||
type: 'mikrotik_unreachable',
|
||||
@@ -76,47 +128,47 @@ async function getAlerts(req, res) {
|
||||
|
||||
if (!resource) return;
|
||||
|
||||
if (resource.cpuLoad != null && resource.cpuLoad >= THRESHOLD_CPU) {
|
||||
if (settings.highCpu.enabled && resource.cpuLoad != null && resource.cpuLoad >= thCpu) {
|
||||
alerts.push({
|
||||
id: `cpu-high-${serverId}`,
|
||||
type: 'high_cpu',
|
||||
severity: 'warning',
|
||||
title: 'Высокое использование CPU',
|
||||
description: `${entityName}: ${resource.cpuLoad}% (порог ${THRESHOLD_CPU}%).`,
|
||||
description: `${entityName}: ${resource.cpuLoad}% (порог ${thCpu}%).`,
|
||||
entity: entityName,
|
||||
entityId: serverId,
|
||||
value: resource.cpuLoad,
|
||||
threshold: THRESHOLD_CPU,
|
||||
threshold: thCpu,
|
||||
link: '/resource-stats',
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
if (resource.memoryUsagePercent != null && resource.memoryUsagePercent >= THRESHOLD_RAM) {
|
||||
if (settings.highRam.enabled && resource.memoryUsagePercent != null && resource.memoryUsagePercent >= thRam) {
|
||||
alerts.push({
|
||||
id: `ram-high-${serverId}`,
|
||||
type: 'high_ram',
|
||||
severity: 'warning',
|
||||
title: 'Высокое использование RAM',
|
||||
description: `${entityName}: ${resource.memoryUsagePercent}% (порог ${THRESHOLD_RAM}%).`,
|
||||
description: `${entityName}: ${resource.memoryUsagePercent}% (порог ${thRam}%).`,
|
||||
entity: entityName,
|
||||
entityId: serverId,
|
||||
value: resource.memoryUsagePercent,
|
||||
threshold: THRESHOLD_RAM,
|
||||
threshold: thRam,
|
||||
link: '/resource-stats',
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
if (resource.hddUsagePercent != null && resource.hddUsagePercent >= THRESHOLD_HDD) {
|
||||
if (settings.highHdd.enabled && resource.hddUsagePercent != null && resource.hddUsagePercent >= thHdd) {
|
||||
alerts.push({
|
||||
id: `hdd-high-${serverId}`,
|
||||
type: 'high_hdd',
|
||||
severity: 'warning',
|
||||
title: 'Высокое использование диска',
|
||||
description: `${entityName}: ${resource.hddUsagePercent}% (порог ${THRESHOLD_HDD}%).`,
|
||||
description: `${entityName}: ${resource.hddUsagePercent}% (порог ${thHdd}%).`,
|
||||
entity: entityName,
|
||||
entityId: serverId,
|
||||
value: resource.hddUsagePercent,
|
||||
threshold: THRESHOLD_HDD,
|
||||
threshold: thHdd,
|
||||
link: '/resource-stats',
|
||||
at: now,
|
||||
});
|
||||
|
||||
@@ -264,32 +264,44 @@ function updateUptimeCache(serverId, entry) {
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/uptime/cache
|
||||
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
|
||||
* Получить данные кеша Uptime Monitor (для оповещений и др.).
|
||||
* Учитывает TTL из ui-settings.uptimeMonitorCacheSeconds.
|
||||
* @returns {Promise<{ results: Record<string, { ok, lastCheckTs, ms }>, updatedAt: number|null }>}
|
||||
*/
|
||||
async function getUptimeCache(req, res) {
|
||||
async function getUptimeCacheData() {
|
||||
try {
|
||||
const uiSettings = await loadUiSettings();
|
||||
const ttlSec = Math.max(0, parseInt(uiSettings.uptimeMonitorCacheSeconds, 10) || 120);
|
||||
if (ttlSec === 0) {
|
||||
return res.json({ results: {}, updatedAt: null });
|
||||
}
|
||||
if (ttlSec === 0) return { results: {}, updatedAt: null };
|
||||
const data = await readS3TextObject(UPTIME_CACHE_KEY).catch(() => null);
|
||||
if (!data?.body) {
|
||||
return res.json({ results: {}, updatedAt: null });
|
||||
}
|
||||
if (!data?.body) return { results: {}, updatedAt: null };
|
||||
let cache = { results: {}, updatedAt: null };
|
||||
try {
|
||||
const parsed = JSON.parse(data.body);
|
||||
if (parsed && typeof parsed === 'object') cache = parsed;
|
||||
} catch (_) {}
|
||||
const now = Date.now();
|
||||
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) {
|
||||
return res.json({ results: {}, updatedAt: null });
|
||||
}
|
||||
return res.json({
|
||||
if (!cache.updatedAt || now - cache.updatedAt > ttlSec * 1000) return { results: {}, updatedAt: null };
|
||||
return {
|
||||
results: cache.results && typeof cache.results === 'object' ? cache.results : {},
|
||||
updatedAt: cache.updatedAt,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[uptime] getUptimeCacheData', e?.message);
|
||||
return { results: {}, updatedAt: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/uptime/cache
|
||||
* Возвращает закешированные результаты проверок, если кеш младше TTL из настроек.
|
||||
*/
|
||||
async function getUptimeCache(req, res) {
|
||||
try {
|
||||
const { results, updatedAt } = await getUptimeCacheData();
|
||||
return res.json({
|
||||
results: Object.keys(results).length ? results : {},
|
||||
updatedAt,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[uptime] getUptimeCache', e);
|
||||
@@ -1547,4 +1559,5 @@ module.exports = {
|
||||
applyAddressListSummary,
|
||||
uptimeCheck,
|
||||
getUptimeCache,
|
||||
getUptimeCacheData,
|
||||
};
|
||||
|
||||
@@ -777,5 +777,6 @@ module.exports = {
|
||||
getPingServices,
|
||||
refreshPingServicesCache,
|
||||
checkOneServerFast,
|
||||
loadUiSettingsSync,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
IconRefresh,
|
||||
IconServer,
|
||||
IconClock,
|
||||
IconBell,
|
||||
IconCpu,
|
||||
IconDeviceDesktop,
|
||||
IconDatabase,
|
||||
} from '@tabler/icons-react';
|
||||
import FormField from './components/FormField';
|
||||
import ErrorAlert from './components/ErrorAlert';
|
||||
@@ -53,6 +57,12 @@ const SIDEBAR_GROUPS = [
|
||||
{ id: 'traffic-interfaces', title: 'Настройка Аналитики', icon: IconChartBar },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Оповещения',
|
||||
items: [
|
||||
{ id: 'alerts', title: 'Настройки оповещений', icon: IconBell },
|
||||
],
|
||||
},
|
||||
];
|
||||
const SIDEBAR_SECTIONS = SIDEBAR_GROUPS.flatMap((g) => g.items);
|
||||
|
||||
@@ -91,6 +101,14 @@ export default function SettingsPage() {
|
||||
const [uptimeMonitorCacheSeconds, setUptimeMonitorCacheSeconds] = useState('120');
|
||||
const [uptimeMonitorSchedulerEnabled, setUptimeMonitorSchedulerEnabled] = useState(true);
|
||||
const [uptimeMonitorSchedulerIntervalMinutes, setUptimeMonitorSchedulerIntervalMinutes] = useState('2');
|
||||
const [alertServerOffline, setAlertServerOffline] = useState(true);
|
||||
const [alertMikrotikUnreachable, setAlertMikrotikUnreachable] = useState(true);
|
||||
const [alertHighCpuEnabled, setAlertHighCpuEnabled] = useState(true);
|
||||
const [alertHighCpuThreshold, setAlertHighCpuThreshold] = useState('85');
|
||||
const [alertHighRamEnabled, setAlertHighRamEnabled] = useState(true);
|
||||
const [alertHighRamThreshold, setAlertHighRamThreshold] = useState('85');
|
||||
const [alertHighHddEnabled, setAlertHighHddEnabled] = useState(true);
|
||||
const [alertHighHddThreshold, setAlertHighHddThreshold] = useState('90');
|
||||
const [serversList, setServersList] = useState([]);
|
||||
const [sidebarSearch, setSidebarSearch] = useState('');
|
||||
const [activeSection, setActiveSection] = useState(() => {
|
||||
@@ -296,6 +314,15 @@ export default function SettingsPage() {
|
||||
}))
|
||||
: []
|
||||
);
|
||||
const a = data?.alertSettings || {};
|
||||
setAlertServerOffline(a.serverOffline?.enabled !== false);
|
||||
setAlertMikrotikUnreachable(a.mikrotikUnreachable?.enabled !== false);
|
||||
setAlertHighCpuEnabled(a.highCpu?.enabled !== false);
|
||||
setAlertHighCpuThreshold(a.highCpu?.thresholdPercent != null ? String(a.highCpu.thresholdPercent) : '85');
|
||||
setAlertHighRamEnabled(a.highRam?.enabled !== false);
|
||||
setAlertHighRamThreshold(a.highRam?.thresholdPercent != null ? String(a.highRam.thresholdPercent) : '85');
|
||||
setAlertHighHddEnabled(a.highHdd?.enabled !== false);
|
||||
setAlertHighHddThreshold(a.highHdd?.thresholdPercent != null ? String(a.highHdd.thresholdPercent) : '90');
|
||||
const e =
|
||||
settingsRes?.headers?.etag || settingsRes?.headers?.ETag || '';
|
||||
setEtag(e ? String(e) : '');
|
||||
@@ -410,6 +437,22 @@ export default function SettingsPage() {
|
||||
interfaceName: p.interfaceName,
|
||||
}))
|
||||
: [],
|
||||
alertSettings: {
|
||||
serverOffline: { enabled: alertServerOffline },
|
||||
mikrotikUnreachable: { enabled: alertMikrotikUnreachable },
|
||||
highCpu: {
|
||||
enabled: alertHighCpuEnabled,
|
||||
thresholdPercent: Math.max(1, Math.min(100, parseInt(alertHighCpuThreshold, 10) || 85)),
|
||||
},
|
||||
highRam: {
|
||||
enabled: alertHighRamEnabled,
|
||||
thresholdPercent: Math.max(1, Math.min(100, parseInt(alertHighRamThreshold, 10) || 85)),
|
||||
},
|
||||
highHdd: {
|
||||
enabled: alertHighHddEnabled,
|
||||
thresholdPercent: Math.max(1, Math.min(100, parseInt(alertHighHddThreshold, 10) || 90)),
|
||||
},
|
||||
},
|
||||
};
|
||||
const payload = { settings: mergedSettings, etag };
|
||||
const res = await api.post('/ui-settings', payload);
|
||||
@@ -1040,6 +1083,171 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeSection === 'alerts' && (
|
||||
<>
|
||||
<SectionHeading title="Настройки оповещений" icon={IconBell} />
|
||||
<p className="text-muted mb-4">
|
||||
Включите или отключите типы оповещений и задайте пороги. Оповещения отображаются на панели и в колокольчике в шапке.
|
||||
</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<h4 className="subheader mb-3">Статус и доступность</h4>
|
||||
<p className="text-muted small mb-3">
|
||||
Для <strong>jumphost/home</strong> используются те же правила, что и в Uptime Monitor: тип проверки (HTTP, internal-ping, external-ping) и кеш. Настройки — в разделе <a href="#uptime-monitor" onClick={(e) => { e.preventDefault(); goToSection('uptime-monitor'); }}>Uptime Monitor</a>. Для остальных серверов — проверка TCP (80/443).
|
||||
</p>
|
||||
<div className="form-check form-switch mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="alertServerOffline"
|
||||
checked={alertServerOffline}
|
||||
onChange={(e) => setAlertServerOffline(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="alertServerOffline">
|
||||
Сервер недоступен — оповещение при недоступности (по правилам Uptime Monitor для роутеров, TCP для остальных).
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-check form-switch mb-3">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="alertMikrotikUnreachable"
|
||||
checked={alertMikrotikUnreachable}
|
||||
onChange={(e) => setAlertMikrotikUnreachable(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="alertMikrotikUnreachable">
|
||||
MikroTik недоступен — срабатывает при ошибке доступа к RouterOS (таймаут, неверный пароль и т.п.).
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h4 className="subheader mb-3">Ресурсы роутеров (MikroTik)</h4>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<span className="avatar avatar-sm me-2 bg-blue-lt text-blue">
|
||||
<IconCpu size={18} />
|
||||
</span>
|
||||
<span className="fw-medium">Использование CPU</span>
|
||||
</div>
|
||||
<div className="form-check form-switch mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="alertHighCpuEnabled"
|
||||
checked={alertHighCpuEnabled}
|
||||
onChange={(e) => setAlertHighCpuEnabled(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label className="form-check-label small" htmlFor="alertHighCpuEnabled">
|
||||
Включить оповещение
|
||||
</label>
|
||||
</div>
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-text">Порог</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={100}
|
||||
value={alertHighCpuThreshold}
|
||||
onChange={(e) => setAlertHighCpuThreshold(e.target.value)}
|
||||
disabled={saving || !alertHighCpuEnabled}
|
||||
/>
|
||||
<span className="input-group-text">%</span>
|
||||
</div>
|
||||
<div className="form-text small">Среднее превышает указанный %.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<span className="avatar avatar-sm me-2 bg-green-lt text-green">
|
||||
<IconDeviceDesktop size={18} />
|
||||
</span>
|
||||
<span className="fw-medium">Использование памяти</span>
|
||||
</div>
|
||||
<div className="form-check form-switch mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="alertHighRamEnabled"
|
||||
checked={alertHighRamEnabled}
|
||||
onChange={(e) => setAlertHighRamEnabled(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label className="form-check-label small" htmlFor="alertHighRamEnabled">
|
||||
Включить оповещение
|
||||
</label>
|
||||
</div>
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-text">Порог</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={100}
|
||||
value={alertHighRamThreshold}
|
||||
onChange={(e) => setAlertHighRamThreshold(e.target.value)}
|
||||
disabled={saving || !alertHighRamEnabled}
|
||||
/>
|
||||
<span className="input-group-text">%</span>
|
||||
</div>
|
||||
<div className="form-text small">Среднее превышает указанный %.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<span className="avatar avatar-sm me-2 bg-orange-lt text-orange">
|
||||
<IconDatabase size={18} />
|
||||
</span>
|
||||
<span className="fw-medium">Использование диска</span>
|
||||
</div>
|
||||
<div className="form-check form-switch mb-2">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="alertHighHddEnabled"
|
||||
checked={alertHighHddEnabled}
|
||||
onChange={(e) => setAlertHighHddEnabled(e.target.checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label className="form-check-label small" htmlFor="alertHighHddEnabled">
|
||||
Включить оповещение
|
||||
</label>
|
||||
</div>
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-text">Порог</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-control"
|
||||
min={1}
|
||||
max={100}
|
||||
value={alertHighHddThreshold}
|
||||
onChange={(e) => setAlertHighHddThreshold(e.target.value)}
|
||||
disabled={saving || !alertHighHddEnabled}
|
||||
/>
|
||||
<span className="input-group-text">%</span>
|
||||
</div>
|
||||
<div className="form-text small">Среднее превышает указанный %.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,9 +85,12 @@ export default function AlertsBell() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card-footer py-2">
|
||||
<Link to="/dashboard" className="btn btn-sm btn-outline-primary w-100">
|
||||
Перейти к панели
|
||||
<div className="card-footer py-2 d-flex gap-2">
|
||||
<Link to="/settings#alerts" className="btn btn-sm btn-ghost-secondary flex-grow-1">
|
||||
Настройки
|
||||
</Link>
|
||||
<Link to="/dashboard" className="btn btn-sm btn-outline-primary flex-grow-1">
|
||||
Панель
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user