@@ -1245,6 +1260,8 @@
fetch('/api/system/stats').then(r => r.json()).then(sysData => {
if (sysData.available) checkThresholds(sysData);
}).catch(() => {});
+ // 同时检查服务下线邮件规则
+ checkEmailRules(null);
} catch (e) {
console.error('加载失败:', e);
@@ -1561,7 +1578,7 @@
return;
}
- const resourceNames = { 'cpu': 'CPU', 'cpu_temp': 'CPU温度', 'memory': '内存', 'disk': '磁盘' };
+ const resourceNames = { 'cpu': 'CPU', 'cpu_temp': 'CPU温度', 'memory': '内存', 'disk': '磁盘', 'service_down': '服务下线' };
const resourceUnits = { 'cpu': '%', 'cpu_temp': '°C', 'memory': '%', 'disk': '%' };
list.innerHTML = rules.map(rule => {
@@ -1575,14 +1592,19 @@
// 显示资源列表(支持多资源和旧版单资源兼容)
const resources = rule.resources || (rule.resource ? [rule.resource] : []);
- const resourceDisplay = resources.map(r => resourceNames[r] || r).join('+');
+ let resourceDisplay;
+ if (resources.includes('service_down') && rule.serviceName) {
+ resourceDisplay = `🔴 ${rule.serviceName} 下线 ≥ ${rule.threshold}分钟`;
+ } else {
+ resourceDisplay = resources.map(r => resourceNames[r] || r).join('+') + ' ≥ ' + rule.threshold;
+ }
return `
${rule.enabled ? '启用' : '禁用'}
${rule.name}
- ${resourceDisplay} ≥ ${rule.threshold}
+ ${resourceDisplay}
${triggerDisplay}
→ ${rule.email}
${silentInfo ? ` ${silentInfo}` : ''}
@@ -1602,9 +1624,13 @@
document.getElementById('emailRuleName').value = '';
// 重置资源复选框
document.querySelectorAll('.email-rule-resource').forEach(cb => cb.checked = false);
+ document.getElementById('serviceDownSelector').style.display = 'none';
+ document.getElementById('emailRuleServiceId').value = '';
document.getElementById('emailRuleThreshold').value = 80;
document.getElementById('emailRuleThresholdUnit').textContent = '% / °C';
+ document.getElementById('emailRuleThresholdHint').textContent = '对不同资源类型使用统一阈值';
document.getElementById('emailRuleTriggerType').value = 'single';
+ document.getElementById('emailRuleTriggerType').disabled = false;
document.getElementById('emailRuleTriggerParam').value = 3;
document.getElementById('triggerParamsDiv').style.display = 'none';
document.getElementById('emailRuleAddress').value = '';
@@ -1629,24 +1655,73 @@
const selected = getSelectedResources();
const unitEl = document.getElementById('emailRuleThresholdUnit');
const thresholdEl = document.getElementById('emailRuleThreshold');
+ const hintEl = document.getElementById('emailRuleThresholdHint');
+ const serviceSelector = document.getElementById('serviceDownSelector');
+ const triggerTypeEl = document.getElementById('emailRuleTriggerType');
+ const triggerParamsDiv = document.getElementById('triggerParamsDiv');
- if (selected.length === 0) {
- unitEl.textContent = '% / °C';
- } else if (selected.length === 1 && selected[0] === 'cpu_temp') {
- unitEl.textContent = '°C';
- if (parseInt(thresholdEl.value) > 120) thresholdEl.value = 80;
- thresholdEl.max = 120;
- thresholdEl.min = 30;
- } else {
- unitEl.textContent = '%';
- thresholdEl.max = 100;
+ const hasServiceDown = selected.includes('service_down');
+
+ // 显示/隐藏服务选择器
+ if (hasServiceDown) {
+ serviceSelector.style.display = 'block';
+ populateServiceSelector();
+ // 服务下线使用分钟作为阈值单位
+ unitEl.textContent = '分钟';
+ thresholdEl.value = 5;
+ thresholdEl.max = 1440;
thresholdEl.min = 1;
- if (selected.includes('cpu_temp')) {
+ hintEl.textContent = '服务连续离线超过此分钟数即触发通知';
+ // 服务下线固定为duration类型
+ if (triggerTypeEl) {
+ triggerTypeEl.value = 'duration';
+ triggerTypeEl.disabled = true;
+ }
+ if (triggerParamsDiv) triggerParamsDiv.style.display = 'none';
+ } else {
+ serviceSelector.style.display = 'none';
+ if (triggerTypeEl) triggerTypeEl.disabled = false;
+ updateTriggerParams();
+
+ if (selected.length === 0) {
unitEl.textContent = '% / °C';
+ hintEl.textContent = '对不同资源类型使用统一阈值';
+ } else if (selected.length === 1 && selected[0] === 'cpu_temp') {
+ unitEl.textContent = '°C';
+ if (parseInt(thresholdEl.value) > 120) thresholdEl.value = 80;
+ thresholdEl.max = 120;
+ thresholdEl.min = 30;
+ hintEl.textContent = 'CPU温度超过此值即触发通知';
+ } else {
+ unitEl.textContent = '%';
+ thresholdEl.max = 100;
+ thresholdEl.min = 1;
+ hintEl.textContent = '对不同资源类型使用统一阈值';
+ if (selected.includes('cpu_temp')) {
+ unitEl.textContent = '% / °C';
+ }
}
}
}
+ function populateServiceSelector() {
+ const sel = document.getElementById('emailRuleServiceId');
+ const currentVal = sel.value;
+ // 收集所有web类型服务
+ const webServices = projects.filter(p => p.type === 'web');
+ let options = '';
+ webServices.forEach(p => {
+ const portsStr = p.ports ? p.ports.join(',') : '';
+ const selected = p.id === currentVal ? ' selected' : '';
+ options += ``;
+ });
+ sel.innerHTML = options;
+ }
+
+ function onServiceDownChange() {
+ // 将来可以在这里做联动
+ }
+
function updateTriggerParams() {
const triggerType = document.getElementById('emailRuleTriggerType').value;
const paramsDiv = document.getElementById('triggerParamsDiv');
@@ -1704,6 +1779,10 @@
if (cb) cb.checked = true;
});
onResourceChange();
+ // 服务下线相关
+ if (rule.serviceId) {
+ document.getElementById('emailRuleServiceId').value = rule.serviceId;
+ }
document.getElementById('emailRuleThreshold').value = rule.threshold;
document.getElementById('emailRuleTriggerType').value = rule.triggerType || 'single';
document.getElementById('emailRuleTriggerParam').value = rule.triggerParam || 3;
@@ -1732,6 +1811,7 @@
const silentEnd = document.getElementById('emailRuleSilentEnd').value;
const silentEnabled = document.getElementById('emailRuleSilentEnabled').checked;
const enabled = document.getElementById('emailRuleEnabled').checked;
+ const serviceId = document.getElementById('emailRuleServiceId').value;
if (!name || !email) {
alert('请填写完整信息');
@@ -1743,23 +1823,32 @@
return;
}
+ // 服务下线必须选择具体服务
+ if (resources.includes('service_down') && !serviceId) {
+ alert('请选择要监控的服务');
+ return;
+ }
+
const rules = getEmailRules();
if (ruleId) {
// 编辑
const idx = rules.findIndex(r => r.id === ruleId);
if (idx >= 0) {
- rules[idx] = { ...rules[idx], name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled };
+ rules[idx] = { ...rules[idx], name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled, serviceId };
}
} else {
// 新增
+ const svc = projects.find(p => p.id === serviceId);
rules.push({
id: 'rule_' + Date.now(),
name, resources, threshold, triggerType, triggerParam, email, interval, silentStart, silentEnd, silentEnabled, enabled,
+ serviceId, serviceName: svc ? svc.name : '',
+ offlineSince: null, // 服务下线开始时间
lastSent: 0,
- exceedCount: 0, // 连续超过次数
- exceedDuration: 0, // 累计超过时间
- recentValues: [] // 最近采样值
+ exceedCount: 0,
+ exceedDuration: 0,
+ recentValues: []
});
}
@@ -1818,6 +1907,33 @@
for (const resource of resources) {
let value;
+
+ // 服务下线监控 - 特殊处理(不需要data参数)
+ if (resource === 'service_down') {
+ const svcId = rule.serviceId;
+ if (!svcId) continue;
+ const svc = projects.find(p => p.id === svcId);
+ if (!svc) continue;
+ const isOnline = svc.status?.status === 'running';
+
+ if (isOnline) {
+ rule.offlineSince = null;
+ } else {
+ if (!rule.offlineSince) {
+ rule.offlineSince = now;
+ }
+ const offlineMinutes = (now - rule.offlineSince) / 60000;
+ if (offlineMinutes >= rule.threshold) {
+ const svcName = svc.name || rule.serviceName || svcId;
+ exceededResources.push(`🔴 ${svcName} 已离线 ${Math.round(offlineMinutes)} 分钟 (阈值: ${rule.threshold} 分钟)`);
+ }
+ }
+ continue;
+ }
+
+ // 系统资源监控需要 data 参数
+ if (!data) continue;
+
if (resource === 'cpu_temp') {
// CPU温度取两个Package中较高的那个
const t0 = data.cpu?.temp_package_0;