Explorar el Código

feat(WecomCenter): per-class & per-trigger severity editing on Maintenance tab; ship plugin via compose (auto module-enable); add usage/deploy doc

COBCO hace 3 días
padre
commit
8d1ac543dd

+ 42 - 1
docker-compose.yaml

@@ -124,7 +124,9 @@ services:
       PHP_TZ: ${TZ:-UTC}
     volumes:
       - /opt/zabbix/zabbix-server/web:/data
-      - /opt/zabbix/zabbix-server/web-modules:/usr/share/zabbix/modules
+      # 插件目录随仓库一起走:默认用 compose 同级的 ./web-modules,
+      # 已有环境可用 WEB_MODULES_DIR 覆盖为绝对路径(如 /opt/zabbix/zabbix-server/web-modules)。
+      - ${WEB_MODULES_DIR:-./web-modules}:/usr/share/zabbix/modules
       - /opt/zabbix/zabbix-server/fonts/simkai.ttf:/usr/share/zabbix/assets/fonts/DejaVuSans.ttf
     depends_on:
       zabbix-mysql:
@@ -132,6 +134,45 @@ services:
       zabbix-server:
         condition: service_started
 
+  # ---------------------------------------------------------------------------
+  # 首次部署自动登记 WecomCenter 模块(幂等):让全新 Docker 环境 up 起来后插件
+  # 就已“启用”,无需手动去 Administration → General → Modules 点启用。
+  # moduleid 在 Zabbix 里不是自增列,这里取 MAX+1 显式写入。
+  # ---------------------------------------------------------------------------
+  wecom-module-init:
+    image: mysql:8.0.43
+    container_name: zabbix-wecom-module-init
+    restart: "no"
+    depends_on:
+      zabbix-mysql:
+        condition: service_healthy
+    environment:
+      MYSQL_PWD: ${MYSQL_ROOT_PASSWORD:-CobcoIT132}
+      MYSQL_DATABASE: ${MYSQL_DATABASE:-zabbix}
+    entrypoint: ["/bin/sh", "-c"]
+    command:
+      - |
+        set -e
+        HOST=zabbix-mysql
+        DB="$${MYSQL_DATABASE}"
+        echo "[wecom-init] waiting for MySQL at $$HOST ..."
+        until mysql -h "$$HOST" -uroot -e "SELECT 1" >/dev/null 2>&1; do sleep 2; done
+        mysql -h "$$HOST" -uroot "$$DB" <<'SQL'
+        SET @nid := (SELECT COALESCE(MAX(moduleid), 0) + 1 FROM module);
+        INSERT INTO module (moduleid, id, relative_path, status, config)
+        SELECT @nid, 'wecom_center', 'modules/WecomCenter', 1, '[]'
+        FROM DUAL
+        WHERE NOT EXISTS (SELECT 1 FROM module WHERE id = 'wecom_center');
+        SQL
+        echo "[wecom-init] module 'wecom_center' ensured (enabled)."
+    networks:
+      - zabbix
+    logging:
+      driver: json-file
+      options:
+        max-size: "5m"
+        max-file: "2"
+
 networks:
   zabbix:
     ipam:

+ 210 - 0
docs/使用说明.md

@@ -0,0 +1,210 @@
+# WeCom Center — 使用与部署说明
+
+> 适用版本:Zabbix **7.4.x**(已在 7.4.16 / MySQL 8.0.43 验证)。
+> 一个自包含、多语言的 Zabbix 前端模块(custom module / plugin)。加载后会在
+> **Administration → WeCom Center** 增加一个定制页面,分 **告警 / 优化 / 日常维护** 三个标签页。
+
+---
+
+## 1. 它能做什么(一句话版)
+
+| 标签页 | 关键能力 |
+|---|---|
+| **告警 Alert** | 企业微信群机器人 key 设置与测试;5 套告警策略预设(**先预览 → 再应用**);手动勾选会通知的严重度;一键刷新(写入模板触发器 / 触发器原型 / 已发现触发器,全部节点生效)。 |
+| **优化 Optimization** | 服务端调优参数生成(只生成 `.env`/compose 片段与重建命令,**不落地**,运维自行重建);真实华为模板 →「导入」→「切换」迁移设备;Geomap 单点标记。 |
+| **日常维护 Maintenance** | **按类别批量改告警等级**;**按单个触发器精确调整**:勾选开关告警 + 行尾下拉改严重度;**保存后页面自动刷新**。 |
+
+所有改动都走 **Zabbix 官方 API / 受控 SQL** 写进数据库,所以**换机器、重建容器、升级镜像,配置都在**——只要「加载插件」即可复用。
+
+---
+
+## 2. 部署
+
+### 2.1 全新 Zabbix(推荐,一条命令就有插件)
+
+仓库自带的 `docker-compose.yaml` 已经把「插件随仓库走 + 自动启用」做好:
+
+```bash
+git clone <本仓库地址> zabbix-deploy
+cd zabbix-deploy
+
+# (可选)已有环境的模块目录在别处时,用环境变量覆盖,默认是仓库同级的 ./web-modules
+# export WEB_MODULES_DIR=/opt/zabbix/zabbix-server/web-modules
+
+docker compose up -d
+```
+
+启动后:
+
+- 文件层面:`./web-modules/WecomCenter` 被挂到容器 `/usr/share/zabbix/modules/WecomCenter`。
+- 数据层面:一次性服务 **`wecom-module-init`** 会等 MySQL 就绪后,把 `module` 表里的 `wecom_center` 登记为 **已启用(status=1)**。脚本是**幂等**的:已存在就不动,重复 `up` 也安全。
+
+> 也就是说:**全新环境 `up -d` 完,什么都不用点,插件已经在了。**
+
+### 2.2 已有 Zabbix(手动装)
+
+1. 确认 web 容器挂载了持久化的 modules 目录(否则重建容器文件会丢):
+   ```yaml
+   zabbix-web-nginx-mysql:
+     volumes:
+       - /opt/zabbix/zabbix-server/web-modules:/usr/share/zabbix/modules
+   ```
+2. 把 `web-modules/WecomCenter` 整个目录拷到宿主那台目录下(即 `.../web-modules/WecomCenter/`)。
+   - 也可用自带脚本:`cd web-modules && ./install.sh`(自动探测运行中的 `zabbix-*` web 容器并复制进去;或 `./install.sh /usr/share/zabbix/modules`)。
+3. `docker compose up -d` 重建 web 容器使挂载生效(只重启 web,数据不动)。
+4. **启用模块**(写 `module` 表,`status=1` 为启用;`moduleid` 不是自增列,需显式给值):
+   ```sql
+   SET @nid := (SELECT COALESCE(MAX(moduleid),0)+1 FROM module);
+   INSERT INTO module(moduleid,id,relative_path,status,config)
+   SELECT @nid,'wecom_center','modules/WecomCenter',1,'[]'
+   WHERE NOT EXISTS (SELECT 1 FROM module WHERE id='wecom_center');
+   ```
+   也可以进 **Administration → General → Modules → Scan directory → 启用 `WecomCenter`**。
+
+### 2.3 换环境 / 升级镜像
+
+- 换机器:把仓库(含 `web-modules/`)拉过去,按 2.1 或 2.2 重来即可;数据库卷保留则配置不丢。
+- 升级镜像:镜像锁定精确版本(`ubuntu-7.4.16`),`up -d` 重建后插件仍在(文件在挂载目录、启用记录在 DB)。
+
+### 2.4 部署验证
+
+1. 左侧菜单出现 **Administration → WeCom Center**,点开能看到三个标签页即成功。
+2. 打开 **日常维护** 页能搜到触发器、每行右侧有严重度下拉,说明读写链路正常。
+3. 看 `/opt/zabbix/zabbix-server/web-modules/WecomCenter/manifest.json` 与 DB 的 `module` 行(`id=wecom_center,status=1`)。
+
+---
+
+## 3. 首次初始化(告警链路一键补全)
+
+安装后第一次打开 **WeCom Center → 告警** 页,会显示 **First-time setup / 首次配置**:
+
+1. 粘贴企业微信群机器人 webhook 的 **Key**(就是 `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...` 里 `key=` 后面那串)。
+2. 点 **初始化环境 / Initialise(自检)**。它会自动补齐整条告警链路,**缺啥建啥、已存在则复用**,可反复点当作自检:
+
+| 对象 | 作用 |
+|---|---|
+| 全局宏 `{$WECOM_KEY}` | 存放机器人 key(换机器人只改这一格) |
+| 媒体类型 `WeCom Group Robot` | type=4 webhook,内置发送脚本(markdown,失败回退 text) |
+| 机器人用户 `wecomrobot` | 挂上面这个媒体类型,severity = High + Disaster |
+| 触发器动作 `Send alarm by Wecom Robot` | 告警发红字、恢复发绿字,均带 `{$ZBX_URL}` 直达详情链接 |
+
+> 群机器人就是「往群里发一条消息」,**没有收件人账号/接收目标**的概念,所以这里不设真人接收者,只维护一个机器人账号承载媒介。
+
+---
+
+## 4. 页面使用详解
+
+### 4.1 告警 Alert
+
+- **机器人 key**:单字段读写全局宏 `{$WECOM_KEY}`;点 **发送测试** 会直接打企微接口,返回 `errcode:0` 即通。
+- **告警策略预设**:内置 5 套(稳健运维 / 只保命 / 全量观察 / 夜间降噪 / 核心从严)。
+  - **先「预览」(dry-run,不写库)**:按类别列出将改动多少触发器。
+  - **再「应用」**:①设媒介严重度阈值;②把「模板触发器、触发器原型、已发现(LLD)触发器」按类别批量改严重度。**必告警类别一律钉到灾难 Disaster(5)**,所以阈值再高也必然发出。可重复执行、可回滚。
+- **手动阈值**:勾选「会通知」的严重度 + 总开关(写媒介 `severity` 位掩码 / `active`)。
+- **一键刷新**:应用/保存后,改动通过 API 立即下发模板、通过受控 SQL 写已发现触发器;**所有探测节点(模板 → 主机)都会同步**。前端读库即时可见,Zabbix server 配置缓存约 **60 秒**内自动同步到告警判定。
+
+### 4.2 优化 Optimization
+
+- **服务端调优生成器**:选 小/中/大 规模,生成 `StartSNMPPollers` 等 `.env`/compose 参数与重建命令。**只生成、不落地**,由运维复制到主机自行 `docker compose up -d` 重建 server(零风险、零特权)。
+- **模板导入**:内置的是**真实的华为模板** `Huawei VRP by SNMP walk`(导出文件在 `templates/`),点「导入」即导入 Zabbix(已存在则就地更新,新模板则新建)。
+- **模板切换(迁移设备)**:选「旧模板 → 新模板」→ **预览**列出受影响设备 → **确认切换**,把每台设备的模板链接替换为新模板(保留其它链接、去重)。
+- **地图标记**:以 **单点(Zabbix server 主机)** 作代表点设置经纬度。
+  > Geomap 底图需**浏览器能访问外网**加载 OSM 瓦片;纯内网底图会空白,但标记点逻辑正确。
+
+### 4.3 日常维护 Maintenance(本次重点)
+
+**A. 按类别批量改告警等级**
+
+1. 「Trigger class」选一个类别(设备离线 / 硬件故障 / 重启 / 核心链路 / 接口 down / 性能 …;`other` 不参与)。
+2. 「Set severity to」选目标严重度(Not classified … Disaster)。
+3. 点 **Apply**。会把该类别下的**模板触发器、触发器原型、已发现(LLD)触发器**一次性改到该等级,并自动刷新列表。
+   - 与「策略引擎」同一套写入逻辑,**完全可逆**;适合「把某一类警报统一拉高/压低」。
+
+**B. 按单个触发器精确调整**
+
+1. 顶部搜索框输入关键字(如 `Temperature`)→ 点 **Search**(最多显示 200 条;全库可能有数万个触发器)。
+2. 每一行:
+   - 左侧 **勾选框**:勾选 = 启用告警,取消 = 停用该触发器(写 `trigger.status`,0 启用 / 1 停用);
+   - 右侧 **下拉框**:直接选该触发器的严重度(0–5)。
+3. 改完点 **Save**,**页面自动刷新**,结果立即列出;server 配置缓存约 60 秒内同步到告警判定。
+
+> 所有调整只改 `triggers.priority` / `triggers.status`,**不删除任何对象**。对「已发现(LLD)触发器」因 Zabbix API 不允许改其 priority,改用**受控 SQL** 精确更新,可逆。
+
+---
+
+## 5. 严重度对照
+
+| 等级 | priority | 媒介位掩码 |
+|---|---|---|
+| 未分类 Not classified | 0 | 1 |
+| 信息 Information | 1 | 2 |
+| 警告 Warning | 2 | 4 |
+| 一般严重 Average | 3 | 8 |
+| 严重 High | 4 | 16 |
+| 灾难 Disaster | 5 | 32 |
+
+- 触发器「是否告警」= `trigger.status`(**0 启用 / 1 停用**)。
+- 媒介「是否生效」= `media.active`(**0 启用 / 1 停用**),严重度用位掩码。
+
+---
+
+## 6. 多语言
+
+- 共 **35 个语言文件**,覆盖 Zabbix 全部 locale(`en_GB`/`en_US` 共用英文),与 Zabbix 语言数量一致。
+- 已人工翻译:`zh_CN`、`zh_TW`、`ja_JP`、`ko_KR`、`ru_RU`、`de_DE`、`fr_FR`、`es_ES`、`pt_BR`、`it_IT`、`pl_PL`、`tr_TR`。
+- 其余语言文件存在,运行时**自动回退英文**(页面照常工作,不报错)。
+- 模块按**当前用户界面语言**自动切换。新增/完善某语言:复制 `locale/en.php` 为 `locale/<lang>.php`,只改 value、键名不变即可。
+
+---
+
+## 7. 常见问题(FAQ)
+
+**Q:改完严重度,为什么告警没立刻变?**
+A:页面(读数据库)会**立刻**显示新值;Zabbix server 的「配置缓存」约 **60 秒**(`CacheUpdateFrequency`)刷新后才作用于告警判定。这是 Zabbix 机制,不是插件问题。
+
+**Q:地图是空白的?**
+A:Geomap 底图依赖浏览器访问外网 OSM 瓦片;纯内网会空白,但标记点逻辑正确。
+
+**Q:会不会误伤生产触发器?**
+A:不会。所有操作都要**你主动点 Apply / Save** 才写库;「预览」不写库;改动仅限 `priority` / `status`,不删对象,且可用备份回滚。
+
+**Q:换机器人 key 要改哪里?**
+A:只改页面「机器人 key」一处(即全局宏 `{$WECOM_KEY}`),或 Administration → Macros。
+
+**Q:能在非 docker 的 Zabbix 上用吗?**
+A:可以。把 `WecomCenter/` 放到 `modules` 目录、启用模块即可(见 2.2)。
+
+**Q:升级 Zabbix 会丢插件吗?**
+A:不会。插件文件在宿主持久化目录、启用记录在数据库;只要目录与数据库卷保留,升级镜像后仍在。
+
+---
+
+## 8. 目录结构
+
+```
+zabbix-deploy/
+├── docker-compose.yaml            # 带插件:挂载 ./web-modules + wecom-module-init 自动启用
+├── web-modules/
+│   ├── install.sh                 # 一步搬运脚本(docker 自动探测 / 指定目录)
+│   ├── README.md                  # 开发者向说明
+│   └── WecomCenter/               # 插件本体
+│       ├── manifest.json          # v2.0,注册 2 个 action
+│       ├── Module.php             # 入口:注册视图、i18n、注入左侧菜单
+│       ├── actions/               # View(GET) / Save(POST) 控制器
+│       ├── views/                 # 页面模板
+│       ├── lib/                   # I18n.php / Helper.php(业务逻辑)
+│       ├── locale/                # 35 个语言文件
+│       ├── templates/             # 内置真实华为模板
+│       └── assets/                # wecom.center.css
+└── docs/使用说明.md               # 本文档
+```
+
+---
+
+## 9. 回滚建议
+
+动手批量改前,建议先导出相关对象做备份(媒介类型、动作、全局宏、触发器 priority 快照)。示例(API 导出):
+```bash
+# 用 API token 导出媒介/动作/宏,保存为 JSON,需要时用对应 API 还原
+```
+页面「预览」永远是 dry-run,先看影响面再应用,是最稳的做法。

+ 17 - 6
web-modules/README.md

@@ -13,7 +13,9 @@
   - **地图标记**:以 **单点(Zabbix server 主机)** 作为代表点,设置 `location_lat` / `location_lon`。
   - **环境初始化 / 自检**:见下文“全新 Zabbix 两步装好”——一键补媒体类型、机器人用户、动作与 `{$WECOM_KEY}` 宏。
 - **日常维护(Maintenance)**
-  - **触发器告警开关**:按名称搜索触发器,勾选 = 启用告警、取消勾选 = 停用该触发器(写 `trigger.status`,0 启用 / 1 停用)。单套环境常有数万个触发器,需先输入关键字搜索(最多显示 200 条)。
+  - **按类别批量改告警等级**:选一个“触发器类别”(如 设备离线 / 硬件故障 / 性能 …)+ 目标严重度,点 **应用**,即把该类别下**模板触发器、触发器原型、已发现(LLD)触发器**一次性改到该等级(复用策略引擎同一套写入逻辑,完全可逆)。适合“把某一类警报统一拉高/压低”。
+  - **按单个触发器精确调整**:按名称搜索触发器(最多显示 200 条),对每行可 ①勾选/取消 = 启用/停用告警(写 `trigger.status`,0 启用 / 1 停用);②行尾下拉直接选该触发器的严重度(0–5)。改完点 **保存**,**页面自动刷新**、即时可见(server 配置缓存约 60 秒内同步到告警逻辑)。
+  - 单套环境常有数万个触发器,需先输入关键字搜索(最多显示 200 条)。所有调整仅改 `triggers.priority` / `status`,不删除任何对象。
 
 所有改动都通过 **Zabbix 官方 API / 受控 SQL** 写入数据库,因此:换机器、重建容器、升级镜像后设置都在;只要“加载插件”即可复用。
 
@@ -64,22 +66,31 @@ WecomCenter/
 
 ## 安装(可移植方式)
 
-1. **持久化模块目录**:在 `docker-compose.yaml` 的 web 服务下挂载宿主目录:
+> **有现成 docker-compose(推荐)**:仓库根目录的 `docker-compose.yaml` 已把插件目录做成
+> `${WEB_MODULES_DIR:-./web-modules}:/usr/share/zabbix/modules}`,并新增一次性服务
+> `wecom-module-init` 会在 MySQL 就绪后**自动把 `wecom_center` 模块登记为已启用**(幂等)。
+> 所以 `git clone` 后直接 `docker compose up -d`,起来就有插件,**无需手动启用**。
+
+1. **持久化模块目录**:web 服务下挂载宿主目录(compose 已内置,默认用仓库同级 `./web-modules`):
 
    ```yaml
    zabbix-web-nginx-mysql:
      volumes:
-       - /opt/zabbix/zabbix-server/web-modules:/usr/share/zabbix/modules
+       - ${WEB_MODULES_DIR:-./web-modules}:/usr/share/zabbix/modules
    ```
+   > 已有环境若模块目录在别处,设环境变量 `WEB_MODULES_DIR=/opt/zabbix/zabbix-server/web-modules` 覆盖即可。
 
-2. 把本 `WecomCenter/` 目录整体放到宿主 `/opt/zabbix/zabbix-server/web-modules/WecomCenter/`,然后
-   `docker compose up -d` 重建一次 web 容器使挂载生效(数据不动,仅 web 短暂重启)。
+2. 把本 `WecomCenter/` 目录整体放到该挂载目录下(仓库里 `web-modules/WecomCenter/` 即是),然后
+   `docker compose up -d` 重建 web 容器使挂载生效(数据不动,仅 web 短暂重启)。
 
 3. **启用模块**(写入 `module` 表,status=1 为启用):
+   - 用仓库 compose 时:`wecom-module-init` 服务**已自动完成**;
+   - 手动方式(非 compose / 想手动):
 
    ```sql
+   SET @nid := (SELECT COALESCE(MAX(moduleid),0)+1 FROM module);
    INSERT INTO module(moduleid,id,relative_path,status,config)
-   SELECT 1001,'wecom_center','modules/WecomCenter',1,'[]'
+   SELECT @nid,'wecom_center','modules/WecomCenter',1,'[]'
    WHERE NOT EXISTS (SELECT 1 FROM module WHERE id='wecom_center');
    ```
 

+ 30 - 6
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -40,6 +40,7 @@ class WecomCenterSave extends CController {
 				switch ($op) {
 					case 'save_alert':      $this->saveAlert($mediatypeid); break;
 					case 'save_triggers':   $this->saveTriggers();          break;
+					case 'save_class_sev':  $this->saveClassSeverity();     break;
 					case 'save_robot':      $this->saveRobot($mediatypeid); break;
 					case 'test_robot':      $this->testRobot($mediatypeid); break;
 					case 'import_template': $this->importTemplate();        break;
@@ -61,6 +62,12 @@ class WecomCenterSave extends CController {
 		$tab = (string) ($_REQUEST['tab'] ?? '');
 		if (in_array($tab, ['alert', 'opt', 'maint'], true)) {
 			$url->setArgument('tab', $tab);
+			if ($tab === 'maint') {
+				$ts = (string) ($_REQUEST['trig_search'] ?? '');
+				if ($ts !== '') {
+					$url->setArgument('trig_search', $ts);
+				}
+			}
 		}
 		$this->setResponse(new CControllerResponseRedirect($url));
 	}
@@ -162,16 +169,33 @@ class WecomCenterSave extends CController {
 		if (!$ids) {
 			throw new Exception(I18n::t('msg.no_triggers'));
 		}
-		$map = [];
+		$changed = 0;
+		$sev_changed = 0;
 		foreach ($ids as $id) {
-			$id = (string) (int) $id;
-			if ($id === '' || $id === '0') {
+			$id = (int) $id;
+			if ($id <= 0) {
 				continue;
 			}
-			$map[$id] = isset($_REQUEST['trig_'.$id]) ? 0 : 1;
+			$status = isset($_REQUEST['trig_'.$id]) ? 0 : 1; // checkbox = enabled
+			$priority = null;
+			if (isset($_REQUEST['pse_'.$id]) && $_REQUEST['pse_'.$id] !== '') {
+				$priority = (int) $_REQUEST['pse_'.$id];
+				$sev_changed++;
+			}
+			Helper::setTriggerState((string) $id, $priority, $status);
+			$changed++;
+		}
+		info(I18n::t('msg.triggers_saved', [$changed]));
+	}
+
+	private function saveClassSeverity(): void {
+		$ck = (string) ($_REQUEST['class_key'] ?? '');
+		$p = (int) ($_REQUEST['class_priority'] ?? -1);
+		if ($ck === '' || $p < 0 || $p > 5) {
+			throw new Exception(I18n::t('msg.bad_class_sev'));
 		}
-		$n = Helper::setTriggersStatus($map);
-		info(I18n::t('msg.triggers_saved', [$n]));
+		$r = Helper::setClassSeverity($ck, $p);
+		info(I18n::t('msg.class_sev_saved', [$r['trigger'] + $r['prototype'], $r['discovered']]));
 	}
 
 	private function saveMap(): void {

+ 17 - 0
web-modules/WecomCenter/assets/wecom.center.css

@@ -15,6 +15,7 @@
 .wc-hero__sub { margin-top: 6px; font-size: 13px; opacity: .9; line-height: 1.5; }
 
 .wc-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; align-items: start; }
+.wc-grid > * { min-width: 0; }
 
 .wc-card {
 	background: #fff;
@@ -23,6 +24,7 @@
 	box-shadow: 0 1px 3px rgba(20,30,50,.06);
 	overflow: hidden;
 	margin-bottom: 16px;
+	min-width: 0;
 }
 .wc-card--wide { grid-column: 1 / -1; }
 
@@ -175,3 +177,18 @@ select.wc-input option { color: #1f2c33; }
 :is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-tab.is-active {
 	color: #7fb0ff; border-bottom-color: #4f8bf0; background: #1f2b3d;
 }
+
+/* per-trigger severity rows (flex, min-width:0 => never widens the card) */
+.wc-trow {
+	display: flex; align-items: center; gap: 12px; min-width: 0;
+	padding: 7px 2px; border-bottom: 1px solid #f2f4f7;
+}
+.wc-trow .wc-tcheck {
+	display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
+	margin: 0; font-size: 13px; font-weight: 400; color: inherit;
+}
+.wc-trow .wc-tmain {
+	flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+}
+.wc-trow select.wc-input { width: 150px; max-width: 150px; flex: 0 0 150px; }
+:is(html[color-scheme="dark"], html[theme="dark-theme"]) .wc-trow { border-bottom-color: #28313d; }

+ 68 - 0
web-modules/WecomCenter/lib/Helper.php

@@ -438,6 +438,74 @@ class Helper {
 		return $changed;
 	}
 
+	/**
+	 * Set severity (priority 0-5) and/or enabled-status (0 enabled / 1 disabled)
+	 * for a single trigger. Handles discovered (LLD) triggers, which cannot be
+	 * updated through the API, by writing directly to the triggers table.
+	 * Pass null to leave a field untouched.
+	 */
+	public static function setTriggerState(string $triggerid, ?int $priority, ?int $status): bool {
+		$tid = (string) (int) $triggerid;
+		if ($tid === '' || $tid === '0') {
+			return false;
+		}
+		$cur = API::Trigger()->get([
+			'triggerids' => [$tid],
+			'output'     => ['triggerid', 'priority', 'status', 'flags']
+		]);
+		if (!$cur) {
+			return false;
+		}
+		$flags = (int) $cur[0]['flags'];
+		if ($priority !== null) {
+			$priority = max(0, min(5, $priority));
+		}
+
+		if ($flags === 4) { // discovered trigger -> SQL only
+			$sets = [];
+			if ($priority !== null) {
+				$sets[] = 'priority='.$priority;
+			}
+			if ($status !== null) {
+				$sets[] = 'status='.((int) $status);
+			}
+			if ($sets) {
+				DBexecute('UPDATE triggers SET '.implode(',', $sets).' WHERE triggerid='.$tid);
+			}
+			return true;
+		}
+
+		$upd = ['triggerid' => $tid];
+		if ($priority !== null && (int) $cur[0]['priority'] !== $priority) {
+			$upd['priority'] = $priority;
+		}
+		if ($status !== null && (int) $cur[0]['status'] !== (int) $status) {
+			$upd['status'] = (string) ((int) $status);
+		}
+		if (count($upd) > 1) {
+			API::Trigger()->update($upd);
+		}
+		return true;
+	}
+
+	/**
+	 * Set one whole class of triggers to a severity, across:
+	 *  - template triggers (API)  - prototypes (API)  - discovered triggers (SQL)
+	 * Reuses the strategy engine so behaviour is identical to presets.
+	 * @return array{trigger:int,prototype:int,discovered:int}
+	 */
+	public static function setClassSeverity(string $classKey, int $priority): array {
+		if ($classKey === 'other' || !isset(self::CLASSES[$classKey])) {
+			throw new Exception('Unknown or unsupported class: '.$classKey);
+		}
+		$tid = self::templateHostid();
+		if ($tid === null) {
+			throw new Exception('Template not found: '.self::TEMPLATE_NAME);
+		}
+		return self::applyStrategy($tid, [$classKey => max(0, min(5, $priority))]);
+	}
+
+
 	/* ================================================================== */
 	/*  ALERT STRATEGY ENGINE                                             */
 	/*  Classify triggers by name keyword, map to severity, apply to the  */

+ 7 - 0
web-modules/WecomCenter/locale/en.php

@@ -15,6 +15,7 @@ return [
 	'sec.template'    => 'Template import',
 	'sec.map'         => 'Map marker',
 	'sec.triggers'    => 'Per-trigger alerting switch',
+	'sec.class_sev'   => 'Bulk severity by class',
 
 	// media type
 	'lbl.mediatype'   => 'Alert media type',
@@ -99,6 +100,10 @@ return [
 	'lbl.trig_search' => 'Search triggers by description (e.g. CPU, disk)',
 	'lbl.trig_list'   => 'Triggers (checked = alerting enabled)',
 	'lbl.trig_hint'   => 'There are {1} triggers. Type a keyword above to search (max 200 shown).',
+	'lbl.trig_edit_hint' => 'Adjust severity or toggle alerting per row, then Save. Changes apply immediately and the list refreshes.',
+	'lbl.class_sev_hint' => 'Pick a class of triggers and a target severity. Apply sets every trigger in that class — template, prototypes and already-discovered triggers — then refreshes the list.',
+	'lbl.class_pick'  => 'Trigger class',
+	'lbl.set_severity' => 'Set severity to',
 
 	// messages
 	'msg.saved'       => 'Alert settings saved.',
@@ -118,6 +123,8 @@ return [
 	'msg.no_triggers' => 'No trigger selected.',
 	'msg.triggers_saved' => 'Trigger alerting updated: {1} trigger(s).',
 	'msg.no_trig_search' => 'No triggers matched your search.',
+	'msg.bad_class_sev' => 'Choose a class and a severity (0-5).',
+	'msg.class_sev_saved' => 'Class severity updated: template triggers {1}, discovered triggers {2}. List refreshed.',
 	'msg.strategy_applied' => 'Strategy "{1}" applied — template triggers: {2}, prototypes: {3}, discovered triggers: {4}.',
 
 	// hero + template switch

+ 7 - 0
web-modules/WecomCenter/locale/zh_CN.php

@@ -14,6 +14,7 @@ return [
 	'sec.template'    => '模板导入',
 	'sec.map'         => '地图标记',
 	'sec.triggers'    => '逐条触发告警开关',
+	'sec.class_sev'   => '按类别批量改严重度',
 
 	'lbl.mediatype'   => '告警媒介',
 	'lbl.alerting_on' => '启用告警',
@@ -88,6 +89,10 @@ return [
 	'lbl.trig_search' => '按触发器描述搜索(如 CPU、磁盘)',
 	'lbl.trig_list'   => '触发器(勾选 = 启用告警)',
 	'lbl.trig_hint'   => '共有 {1} 个触发器。在上方输入关键词搜索(最多显示 200 条)。',
+	'lbl.trig_edit_hint' => '逐行调整严重度或开关告警,然后点「保存」。改动即时生效,列表会自动刷新。',
+	'lbl.class_sev_hint' => '选择一类触发器和一个目标严重度,点「应用」会把该类下所有触发器——整个模板、原型以及已发现实例——统一改掉,随后自动刷新列表。',
+	'lbl.class_pick'  => '触发器类别',
+	'lbl.set_severity' => '严重度设为',
 
 	'msg.saved'       => '告警设置已保存。',
 	'msg.robot_saved' => '机器人 key 已保存。',
@@ -106,6 +111,8 @@ return [
 	'msg.no_triggers' => '未选择触发器。',
 	'msg.triggers_saved' => '触发器告警已更新:{1} 个。',
 	'msg.no_trig_search' => '没有匹配的触发器。',
+	'msg.bad_class_sev' => '请选择类别与严重度(0–5)。',
+	'msg.class_sev_saved' => '类别严重度已更新:模板触发器 {1} 个,已发现触发器 {2} 个。列表已刷新。',
 	'msg.strategy_applied' => '策略“{1}”已应用 —— 模板触发器:{2},触发器原型:{3},发现触发器:{4}。',
 
 	'hero.sub'        => '企业微信群告警、一键告警策略、服务端调优、模板迁移与逐条控制,全部集中在这里。',

+ 38 - 8
web-modules/WecomCenter/views/wecom.center.view.php

@@ -259,6 +259,30 @@ $tab_opt = '<div class="wc-grid">'.$tuningCard.$importCard.$mapCard.$initCard2.$
 
 /* ======================== TAB 3: MAINTENANCE ========================== */
 
+// -- by class: pick a class + a severity, apply to every trigger in that class --
+$classOpts = '';
+foreach ($data['classes'] as $ck => $c) {
+	if ($ck === 'other') {
+		continue;
+	}
+	$classOpts .= '<option value="'.$e($ck).'">'.$t($c['label']).'</option>';
+}
+$prioOpts = '';
+for ($p = 0; $p <= 5; $p++) {
+	$prioOpts .= '<option value="'.$p.'"'.($p === 5 ? ' selected' : '').'>'.$t('sev.'.($p + 1)).'</option>';
+}
+$classBody =
+	'<div class="wc-hint">'.$t('lbl.class_sev_hint').'</div>'
+	.'<form method="post" action="'.$save.'"><input type="hidden" name="tab" value="maint">'
+	.'<div class="wc-inline">'
+	.'<div class="wc-field" style="flex:2;min-width:200px"><label>'.$t('lbl.class_pick').'</label><select class="wc-input" name="class_key">'.$classOpts.'</select></div>'
+	.'<div class="wc-field" style="flex:1;min-width:140px"><label>'.$t('lbl.set_severity').'</label><select class="wc-input" name="class_priority">'.$prioOpts.'</select></div>'
+	.'</div>'
+	.'<div class="wc-actions"><button class="wc-btn wc-btn--primary" type="submit" name="op" value="save_class_sev">'.$t('btn.apply').'</button></div>'
+	.'</form>';
+$classCard = $card('&#127991;&#65039;', $t('sec.class_sev'), $classBody, ' wc-card--wide');
+
+// -- by single trigger: search, then adjust severity / enable per row --
 $searchBody =
 	'<div class="wc-hint">'.$t('lbl.trig_hint', [$data['trigger_count']]).'</div>'
 	.'<form method="get" action="zabbix.php"><input type="hidden" name="action" value="wecom.center.view">'
@@ -270,22 +294,27 @@ $maintBody = $searchBody;
 if ($data['trig_search'] !== '') {
 	if ($data['triggers']) {
 		$ids = [];
-		$list = '';
+		$rows = '';
 		foreach ($data['triggers'] as $tg) {
 			$ids[] = $tg['triggerid'];
 			$host = !empty($tg['hosts']) ? $tg['hosts'][0]['host'] : '';
-			$sev = ((int) $tg['priority']) + 1;
 			$chk = ((int) $tg['status'] === 0) ? ' checked' : '';
-			$list .= '<label class="wc-checkrow" style="display:block;padding:6px 0;border-bottom:1px solid #f2f4f7">'
-				.'<input type="checkbox" name="trig_'.$e($tg['triggerid']).'"'.$chk.'> '
-				.'<span class="wc-sev wc-sev-'.$sev.'">'.$t('sev.'.$sev).'</span> '
-				.'<span class="wc-muted">'.$e($host).'</span> — '.$e($tg['description']).'</label>';
+			$sevSel = '';
+			for ($p = 0; $p <= 5; $p++) {
+				$sevSel .= '<option value="'.$p.'"'.(((int) $tg['priority'] === $p) ? ' selected' : '').'>'.$t('sev.'.($p + 1)).'</option>';
+			}
+			$rows .= '<div class="wc-trow">'
+				.'<label class="wc-tcheck"><input type="checkbox" name="trig_'.$e($tg['triggerid']).'"'.$chk.'> <span class="wc-tmain">'.($host !== '' ? $e($host).' — ' : '').$e($tg['description']).'</span></label>'
+				.'<select class="wc-input" name="pse_'.$e($tg['triggerid']).'">'.$sevSel.'</select>'
+				.'</div>';
 		}
 		$maintBody .= '<div class="wc-divider"></div>'
 			.'<form method="post" action="'.$save.'">'
 			.'<input type="hidden" name="trig_ids" value="'.$e(implode(',', $ids)).'">'
 			.'<input type="hidden" name="tab" value="maint">'
-			.'<div class="wc-field"><label>'.$t('lbl.trig_list').'</label>'.$list.'</div>'
+			.'<input type="hidden" name="trig_search" value="'.$e($data['trig_search']).'">'
+			.'<div class="wc-hint">'.$t('lbl.trig_edit_hint').'</div>'
+			.'<div class="wc-field"><label>'.$t('lbl.trig_list').'</label>'.$rows.'</div>'
 			.'<div class="wc-actions"><button class="wc-btn wc-btn--primary" type="submit" name="op" value="save_triggers">'.$t('btn.save').'</button></div>'
 			.'</form>';
 	}
@@ -293,7 +322,8 @@ if ($data['trig_search'] !== '') {
 		$maintBody .= '<div class="wc-divider"></div><div class="wc-empty">'.$t('msg.no_trig_search').'</div>';
 	}
 }
-$tab_maint = '<div class="wc-grid">'.$card('&#128295;', $t('sec.triggers'), $maintBody, ' wc-card--wide').'</div>';
+$trigCard = $card('&#128295;', $t('sec.triggers'), $maintBody, ' wc-card--wide');
+$tab_maint = '<div class="wc-grid">'.$classCard.$trigCard.'</div>';
 
 /* ============================ assemble =============================== */
 $hero = '<div class="wc-hero"><div class="wc-hero__title">'.$t('page.title').'</div>'