Jelajahi Sumber

feat(WecomCenter): 3-tab UI + alert strategy engine (must-alert=Disaster) + server tuning generator; tested apply/restore fleet-wide

DSH Agent 4 hari lalu
induk
melakukan
851186ce46

+ 44 - 7
web-modules/README.md

@@ -1,14 +1,19 @@
 # WeCom Center — 可移植 Zabbix 前端模块
 
-一个自包含、多语言的 Zabbix 7.4 前端模块(custom module / plugin)。加载后会在 **Administration → WeCom Center** 增加一个定制页面,集中处理:
+一个自包含、多语言的 Zabbix 7.4 前端模块(custom module / plugin)。加载后会在 **Administration → WeCom Center** 增加一个定制页面,分三个 Tab:
 
-- **告警优化(核心)**:以勾选方式一键切换“是否告警”与通知严重度;内置 5 套告警方案(标准 / 警告以上 / 全部 / 仅灾难 / 关闭),点一下立即生效。
-- **触发器告警开关**:按名称搜索触发器,勾选 = 启用告警、取消勾选 = 停用该触发器(写 `trigger.status`,0 启用 / 1 停用)。由于单套环境常有数万个触发器,需先输入关键字搜索(最多显示 200 条)。
-- **机器人与账号**:在同一页编辑企业微信 Webhook 地址 / 机器人 key、机器人账号、接收目标,并可“发送测试”验证(直接调用企微接口,返回 errcode)。
-- **模板导入**:把放在 `templates/` 目录里的模板文件(yaml/xml/json)一键导入 Zabbix。
-- **地图坐标**:批量/单个设置主机的 `location_lat` / `location_lon`(主机资产清单)。
+- **告警(Alert)**
+  - **机器人 key**:单字段读写全局宏 `{$WECOM_KEY}`,无“账号/接收目标”概念(群机器人就是往群里发一条消息);可“发送测试”直接打企微接口看 `errcode`。
+  - **告警策略引擎**:内置 5 套策略预设(稳健运维 / 只保命 / 全量观察 / 夜间降噪 / 核心从严)。**先“预览”(dry-run,不写入)→ 再“应用”**。应用会:① 设置媒介严重度阈值;② 把模板触发器、触发器原型、已发现(LLD)触发器按类别批量改严重度。必告警类别一律钉到 **灾难(Disaster=5)**,因此无论阈值多高都必然发出。
+  - **手动阈值**:勾选会通知的严重度 + 总开关(写媒介 `severity` / `active`)。
+- **优化(Optimization)**
+  - **服务端调优生成器**:选“小/中/大”规模,生成 `StartSNMPPollers` 等 `.env`/compose 参数与重建命令——**只生成、不落地**,由运维复制到主机自行重建 server 容器(零风险、零特权)。
+  - **模板导入**:导入 `templates/` 目录里的 yaml/xml/json 模板。
+  - **地图标记**:以 **单点(Zabbix server 主机)** 作为代表点,设置 `location_lat` / `location_lon`。
+- **日常维护(Maintenance)**
+  - **触发器告警开关**:按名称搜索触发器,勾选 = 启用告警、取消勾选 = 停用该触发器(写 `trigger.status`,0 启用 / 1 停用)。单套环境常有数万个触发器,需先输入关键字搜索(最多显示 200 条)。
 
-所有改动都通过 **Zabbix 官方 API** 写入数据库,因此:换机器、重建容器、升级镜像后设置都在;只要“加载插件”即可复用。
+所有改动都通过 **Zabbix 官方 API / 受控 SQL** 写入数据库,因此:换机器、重建容器、升级镜像后设置都在;只要“加载插件”即可复用。
 
 ## 目录结构
 
@@ -89,3 +94,35 @@ WecomCenter/
 - **备份与回滚**:改动前已导出 `/opt/zabbix/backup/wecom-config-backup-*.json`(含媒介 102、动作 7、全局宏),可用对应 API 还原。
 
 若环境中没有 Webhook 媒介类型,页面会提示“未找到 webhook 媒介类型”。
+
+## 告警策略引擎(核心)
+
+**设计原则**:每条策略里,“必告警”类别一律设为最高严重度 **灾难 Disaster(5)**——因为“无论如何,最严重的总得告警”,这样无论阈值怎么卡都必然通过。策略同时给其余类别定阈值。**没有默认策略**:页面上列出预设,用户“选择 → 预览 → 应用”,应用可重复执行。
+
+| 预设 | 阈值(最低会通知的级别) | 适用 |
+|---|---|---|
+| 稳健运维 steady | 严重(High) 及以上 | 日常生产 |
+| 只保命 core | 仅灾难(Disaster) | 最大降噪 |
+| 全量观察 observe | 警告(Warning) 及以上 | 新接入/排障 |
+| 夜间降噪 night | 仅灾难 | 夜班静默 |
+| 核心从严 balanced | 一般严重(Average) 及以上 | 比稳健略宽 |
+
+**类别与“必告警”**(按优先级顺序判定,一个触发器只归一类,高优先覆盖低优先):
+`device_down(设备离线) > hw_fault(硬件故障) > restarted(重启) > core_link(核心/上联) > interface_down(接口down) > perf(性能) > other(不动)`。
+`device_down / hw_fault / restarted / core_link` 属于必告警,钉到 Disaster。
+
+**一次“应用”实际改哪些对象**(三管齐下,缺一不可):
+1. **模板触发器**(普通触发器):`API trigger.update`,立即生效并自动下发到已链接主机。
+2. **触发器原型**:`API triggerprototype.update`,保证以后重新发现的新触发器也一致。
+3. **已发现触发器(flags=4,LLD 生成,无 templateid)**:`trigger.update` 会报 `Cannot update "priority" for a discovered trigger`,故用**受控 SQL** `UPDATE triggers SET priority` 按类别批量改;分“低优先级类别先写、高优先级后写”,确保重叠时高优先胜出。前端读库即时可见,server 配置缓存约 60 秒内自动刷新。
+
+**实测结论(2026-11-04,单套 186 台华为交换机 / 全库 45,824 触发器)**:
+- 走页面后端真实点“应用 steady”:模板触发器 4 个、原型 5 个、已发现触发器 11,047 个全部改成功;抽检 `73801 Unavailable by ICMP ping` 4→5、`73807 Fan critical` 原型 3→5 ✅。
+- 应用后已做**全量还原**:全部 45,824 行 `priority` 与快照比对 **0 误差**,媒介严重度/策略宏一并复位。证明“一键刷新”在所有探测节点上可运行且**完全可逆**。
+- “预览(dry-run)”按类别列出触发数/将改动数,不写入。已在页面验证渲染正常、无 PHP 报错。
+
+## 已知限制(如实说明)
+
+- **核心 vs 接入的 `Link down` 无法在原型层面区分**(只有一个原型):两者统一设为“一般严重(Average=3)”,71 条核心链路不会被单独拔高;如需对核心链路更敏感,后续可加事件规则/标签区分。
+- **模板“导入后切换”迁移**:当前环境旧模板 `Huawei VRP by SNMP(10229)` 挂载主机数为 0,全部 186 台已在 `Huawei VRP by SNMP walk(10962)`,因此“旧→新迁移”目前是空操作;页面已具备导入能力,若确有旧模板带机迁移需求需再补“切换”逻辑与确认预览。
+- **Geomap 底图**:Zabbix Geomap 组件需**浏览器能访问外网**加载 OSM 瓦片;纯内网环境下底图会空白(标记点逻辑仍正确)。

+ 41 - 53
web-modules/WecomCenter/actions/WecomCenterSave.php

@@ -29,42 +29,22 @@ class WecomCenterSave extends CController {
 
 	protected function doAction() {
 		$mediatypeid = (string) ($_REQUEST['mediatypeid'] ?? '');
-		$hostid = (string) ($_REQUEST['hostid'] ?? '');
 
 		try {
-			// One-click alert scheme presets: buttons named scheme_<key>.
-			$applied = false;
-			foreach (array_keys(Helper::SCHEMES) as $k) {
-				if (isset($_REQUEST['scheme_'.$k])) {
-					$this->applyScheme($mediatypeid, $k);
-					$applied = true;
-					break;
-				}
+			// Strategy apply buttons are submitted as name="apply_strategy" value=<preset>.
+			if (isset($_REQUEST['apply_strategy'])) {
+				$this->applyStrategy($mediatypeid, (string) $_REQUEST['apply_strategy']);
 			}
-
-			if (!$applied) {
+			else {
 				$op = (string) ($_REQUEST['op'] ?? '');
 				switch ($op) {
-					case 'save_alert':
-						$this->saveAlert($mediatypeid);
-						break;
-					case 'save_triggers':
-						$this->saveTriggers();
-						break;
-					case 'save_robot':
-						$this->saveRobot($mediatypeid);
-						break;
-					case 'test_robot':
-						$this->testRobot($mediatypeid);
-						break;
-					case 'import_template':
-						$this->importTemplate();
-						break;
-					case 'save_map':
-						$this->saveMap();
-						break;
-					default:
-						error(I18n::t('msg.unknown_op'));
+					case 'save_alert':      $this->saveAlert($mediatypeid); break;
+					case 'save_triggers':   $this->saveTriggers();          break;
+					case 'save_robot':      $this->saveRobot($mediatypeid); break;
+					case 'test_robot':      $this->testRobot($mediatypeid); break;
+					case 'import_template': $this->importTemplate();        break;
+					case 'save_map':        $this->saveMap();               break;
+					default: error(I18n::t('msg.unknown_op'));
 				}
 			}
 		}
@@ -76,25 +56,36 @@ class WecomCenterSave extends CController {
 		if ($mediatypeid !== '') {
 			$url->setArgument('mediatypeid', $mediatypeid);
 		}
-		if ($hostid !== '') {
-			$url->setArgument('hostid', $hostid);
-		}
 		$this->setResponse(new CControllerResponseRedirect($url));
 	}
 
-	private function userName(): string {
-		$n = trim((string) ($_REQUEST['username'] ?? ''));
-		return $n !== '' ? $n : 'wecomrobot';
-	}
-
-	private function applyScheme(string $mediatypeid, string $scheme): void {
-		if (!array_key_exists($scheme, Helper::SCHEMES)) {
+	private function applyStrategy(string $mediatypeid, string $key): void {
+		if (!isset(Helper::STRATEGY_PRESETS[$key])) {
 			throw new Exception(I18n::t('msg.bad_scheme'));
 		}
-		[$mask, $active] = Helper::SCHEMES[$scheme];
-		$user = Helper::ensureRobotUser($this->userName());
-		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active);
-		info(I18n::t('msg.scheme_applied', [I18n::t('scheme.'.$scheme)]));
+		$tid = Helper::templateHostid();
+		if ($tid === null) {
+			throw new Exception(I18n::t('msg.no_template_host'));
+		}
+		$preset = Helper::STRATEGY_PRESETS[$key];
+
+		// 1) threshold on the media (global "how severe still notifies")
+		if ($mediatypeid !== '') {
+			$user = Helper::ensureRobotUser('wecomrobot');
+			$mask = Helper::severityMaskFromMin((int) $preset['min']);
+			Helper::applyMedia($user['userid'], $mediatypeid, $mask, 0);
+		}
+
+		// 2) classify + write severities
+		$res = Helper::applyStrategy($tid, $preset['targets']);
+		Helper::saveStrategyKey($key);
+
+		info(I18n::t('msg.strategy_applied', [
+			I18n::t($preset['label']),
+			$res['trigger'],
+			$res['prototype'],
+			$res['discovered']
+		]));
 	}
 
 	private function saveAlert(string $mediatypeid): void {
@@ -107,11 +98,10 @@ class WecomCenterSave extends CController {
 		}
 		$mask = Helper::severityMask($indices);
 		if ($mask === 0 && $active === 0) {
-			$mask = 2; // keep at least Information when enabled
+			$mask = 2;
 		}
-		$user = Helper::ensureRobotUser($this->userName());
-		$sendto = (string) ($_REQUEST['sendto'] ?? 'WeCom');
-		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active, $sendto);
+		$user = Helper::ensureRobotUser('wecomrobot');
+		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active);
 		info(I18n::t('msg.saved'));
 	}
 
@@ -120,12 +110,11 @@ class WecomCenterSave extends CController {
 		if ($url !== '') {
 			Helper::setWebhookKey($url);
 		}
-		$user = Helper::ensureRobotUser($this->userName());
+		$user = Helper::ensureRobotUser('wecomrobot');
 		$cur = Helper::currentMedia($user, $mediatypeid);
 		$mask = $cur['severity'] ?? 48;
 		$active = $cur['active'] ?? 0;
-		$sendto = (string) ($_REQUEST['sendto'] ?? ($cur['sendto'] ?? 'WeCom'));
-		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active, $sendto);
+		Helper::applyMedia($user['userid'], $mediatypeid, $mask, $active);
 		info(I18n::t('msg.robot_saved'));
 	}
 
@@ -173,7 +162,6 @@ class WecomCenterSave extends CController {
 			if ($id === '' || $id === '0') {
 				continue;
 			}
-			// checked => alerting enabled => trigger status 0; unchecked => disabled => 1
 			$map[$id] = isset($_REQUEST['trig_'.$id]) ? 0 : 1;
 		}
 		$n = Helper::setTriggersStatus($map);

+ 61 - 34
web-modules/WecomCenter/actions/WecomCenterView.php

@@ -30,10 +30,8 @@ class WecomCenterView extends CController {
 
 		$mediatypes = Helper::webhookMediatypes();
 
-		// selected media type
 		$mediatypeid = (string) ($_REQUEST['mediatypeid'] ?? '');
 		if ($mediatypeid === '' && $mediatypes) {
-			// prefer one whose name mentions wecom
 			foreach ($mediatypes as $mt) {
 				if (stripos($mt['name'], 'wecom') !== false) {
 					$mediatypeid = (string) $mt['mediatypeid'];
@@ -47,7 +45,6 @@ class WecomCenterView extends CController {
 
 		$mediatype = null;
 		$webhook_url = '';
-		$robot = null;
 		$media = null;
 		if ($mediatypeid !== '') {
 			$mediatype = Helper::getMediatype($mediatypeid);
@@ -58,44 +55,74 @@ class WecomCenterView extends CController {
 			$media = Helper::currentMedia($robot, $mediatypeid);
 		}
 
-		// selected host for map tab
-		$selected_hostid = (string) ($_REQUEST['hostid'] ?? '');
-		$host_loc = ['location_lat' => '', 'location_lon' => ''];
-		if ($selected_hostid !== '') {
-			$h = Helper::hostLocation($selected_hostid);
-			$inv = $h['inventory'] ?? [];
-			$host_loc = [
-				'location_lat' => $inv['location_lat'] ?? '',
-				'location_lon' => $inv['location_lon'] ?? ''
-			];
+		// ---- alert strategy ----
+		$template_hostid = Helper::templateHostid();
+		$current_strategy = Helper::currentStrategy();
+
+		$preview = null;
+		$preview_key = (string) ($_REQUEST['preview_preset'] ?? '');
+		if ($preview_key !== '' && isset(Helper::STRATEGY_PRESETS[$preview_key]) && $template_hostid !== null) {
+			$preview = Helper::previewStrategy($template_hostid,
+				Helper::STRATEGY_PRESETS[$preview_key]['targets']);
+		}
+
+		// ---- server tuning generator ----
+		$tuning_profile = (string) ($_REQUEST['tuning_profile'] ?? 'medium');
+		if (!isset(Helper::TUNING_PROFILES[$tuning_profile])) {
+			$tuning_profile = 'medium';
+		}
+		$tuning = Helper::tuningSnippet($tuning_profile);
+
+		// ---- map: single marker = Zabbix server host ----
+		$server_host = null;
+		$server_loc = ['location_lat' => '', 'location_lon' => ''];
+		try {
+			$sh = \API::Host()->get([
+				'output'          => ['hostid', 'name'],
+				'filter'          => ['host' => 'Zabbix server'],
+				'selectInventory' => ['location_lat', 'location_lon'],
+				'limit'           => 1
+			]);
+			if ($sh) {
+				$server_host = $sh[0];
+				$server_loc = [
+					'location_lat' => $sh[0]['inventory']['location_lat'] ?? '',
+					'location_lon' => $sh[0]['inventory']['location_lon'] ?? ''
+				];
+			}
 		}
+		catch (\Throwable $e) { /* ignore */ }
 
-		// triggers (per-trigger alerting switch, requires a search term)
+		// ---- maintenance: per-trigger alerting switch ----
 		$trig_search = trim((string) ($_REQUEST['trig_search'] ?? ''));
 		$triggers = Helper::triggers($trig_search);
 		$trigger_count = Helper::triggerCount();
 
 		$data = [
-			'title'        => I18n::t('page.title'),
-			'mediatypes'   => $mediatypes,
-			'mediatypeid'  => $mediatypeid,
-			'mediatype'    => $mediatype,
-			'webhook_url'  => $webhook_url,
-			'robot'        => $robot,
-			'robot_name'   => 'wecomrobot',
-			'media'        => $media,
-			'severity'     => $media['severity'] ?? 48,
-			'active'       => $media['active'] ?? 0,
-			'sendto'       => $media['sendto'] ?? 'WeCom',
-			'schemes'      => Helper::SCHEMES,
-			'templates'    => Helper::bundledTemplates($module_dir),
-			'hosts'        => Helper::hosts(),
-			'selected_hostid' => $selected_hostid,
-			'host_loc'     => $host_loc,
-			'trig_search'  => $trig_search,
-			'triggers'     => $triggers,
-			'trigger_count' => $trigger_count,
-			'lang'         => I18n::lang()
+			'title'           => I18n::t('page.title'),
+			'mediatypes'      => $mediatypes,
+			'mediatypeid'     => $mediatypeid,
+			'mediatype'       => $mediatype,
+			'webhook_url'     => $webhook_url,
+			'media'           => $media,
+			'severity'        => $media['severity'] ?? 48,
+			'active'          => $media['active'] ?? 0,
+			'presets'         => Helper::STRATEGY_PRESETS,
+			'classes'         => Helper::CLASSES,
+			'current_strategy'=> $current_strategy,
+			'template_hostid' => $template_hostid,
+			'preview_key'     => $preview_key,
+			'preview'         => $preview,
+			'tuning_profile'  => $tuning_profile,
+			'tuning'          => $tuning,
+			'tuning_profiles' => Helper::TUNING_PROFILES,
+			'templates'       => Helper::bundledTemplates($module_dir),
+			'server_host'     => $server_host,
+			'server_loc'      => $server_loc,
+			'trig_search'     => $trig_search,
+			'triggers'        => $triggers,
+			'trigger_count'   => $trigger_count,
+			'lang'            => I18n::lang()
 		];
 
 		$response = new CControllerResponseData($data);

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

@@ -437,4 +437,311 @@ class Helper {
 		}
 		return $changed;
 	}
+
+	/* ================================================================== */
+	/*  ALERT STRATEGY ENGINE                                             */
+	/*  Classify triggers by name keyword, map to severity, apply to the  */
+	/*  template triggers + prototypes (API) and discovered triggers (SQL)*/
+	/* ================================================================== */
+
+	public const TEMPLATE_NAME  = 'Huawei VRP by SNMP walk';
+	public const STRATEGY_MACRO = '{$WECOM_STRATEGY}';
+
+	// Ordered classes - first match wins (highest priority first).
+	public const CLASSES = [
+		'device_down' => [
+			'label' => 'cls.device_down',
+			'kw'    => ['unavailable by icmp', 'icmp ping loss', 'no snmp data', 'no data', 'unreachable']
+		],
+		'hw_fault' => [
+			'label' => 'cls.hw_fault',
+			'kw'    => ['fan is in critical', 'power supply', 'temperature is above critical',
+				'temperature is too low', 'is in critical state', 'psu']
+		],
+		'restarted' => [
+			'label' => 'cls.restarted',
+			'kw'    => ['has been restarted', 'has been rebooted']
+		],
+		'core_link' => [
+			'label' => 'cls.core_link',
+			'kw'    => ['link down'],
+			'core'  => ['to:', 'dist', 'core', 'csw', 'uplink', 'aggregat', '汇聚', 'trunk']
+		],
+		'interface_down' => [
+			'label' => 'cls.interface_down',
+			'kw'    => ['link down']
+		],
+		'perf' => [
+			'label' => 'cls.perf',
+			'kw'    => ['high bandwidth usage', 'high error rate', 'high cpu', 'high memory', 'utilization',
+				'high icmp ping response time', 'changed to lower speed', 'temperature is above warning']
+		],
+		'other' => ['label' => 'cls.other', 'kw' => []]
+	];
+
+	// Selectable alert strategies. min = lowest severity index that still notifies.
+	public const STRATEGY_PRESETS = [
+		'steady' => [
+			'label' => 'preset.steady', 'desc' => 'preset.steady.desc', 'min' => 4,
+			'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
+				'interface_down' => 3, 'perf' => 2]
+		],
+		'core' => [
+			'label' => 'preset.core', 'desc' => 'preset.core.desc', 'min' => 5,
+			'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
+				'interface_down' => 2, 'perf' => 1]
+		],
+		'observe' => [
+			'label' => 'preset.observe', 'desc' => 'preset.observe.desc', 'min' => 2,
+			'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
+				'interface_down' => 3, 'perf' => 2]
+		],
+		'night' => [
+			'label' => 'preset.night', 'desc' => 'preset.night.desc', 'min' => 5,
+			'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
+				'interface_down' => 2, 'perf' => 1]
+		],
+		'balanced' => [
+			'label' => 'preset.balanced', 'desc' => 'preset.balanced.desc', 'min' => 3,
+			'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
+				'interface_down' => 3, 'perf' => 2]
+		]
+	];
+
+	/** Classify one trigger description into a class key. */
+	public static function classifyRaw(string $desc): string {
+		$d = strtolower($desc);
+		foreach (self::CLASSES as $key => $def) {
+			if (empty($def['kw'])) {
+				continue;
+			}
+			$hit = false;
+			foreach ($def['kw'] as $k) {
+				if (strpos($d, $k) !== false) {
+					$hit = true;
+					break;
+				}
+			}
+			if (!$hit) {
+				continue;
+			}
+			if ($key === 'core_link') {
+				if (self::hasCoreToken($d)) {
+					return 'core_link';
+				}
+				continue; // non-core link-down -> interface_down
+			}
+			return $key;
+		}
+		return 'other';
+	}
+
+	private static function hasCoreToken(string $d): bool {
+		foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) {
+			if (strpos($d, $t) !== false) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	public static function templateHostid(): ?string {
+		try {
+			$res = API::Template()->get([
+				'output' => ['templateid', 'host'],
+				'filter' => ['host' => self::TEMPLATE_NAME],
+				'limit'  => 1
+			]);
+			if ($res) {
+				return (string) $res[0]['templateid'];
+			}
+			$res = API::Host()->get([
+				'output' => ['hostid', 'host'],
+				'filter' => ['host' => self::TEMPLATE_NAME],
+				'limit'  => 1
+			]);
+			return $res ? (string) $res[0]['hostid'] : null;
+		}
+		catch (\Throwable $e) {
+			return null;
+		}
+	}
+
+	public static function severityMaskFromMin(int $min): int {
+		$mask = 0;
+		for ($i = max(1, $min); $i <= 6; $i++) {
+			$mask |= (1 << ($i - 1));
+		}
+		return $mask;
+	}
+
+	public static function currentStrategy(): string {
+		$v = self::getGlobalMacro(self::STRATEGY_MACRO);
+		return $v === null ? '' : (string) $v;
+	}
+
+	public static function saveStrategyKey(string $key): void {
+		self::setGlobalMacro(self::STRATEGY_MACRO, $key);
+	}
+
+	private static function sqlEsc(string $s): string {
+		return str_replace(["\\", "'"], ["\\\\", "''"], $s);
+	}
+
+	private static function classCondSql(string $key): string {
+		$def = self::CLASSES[$key] ?? null;
+		if (!$def || empty($def['kw'])) {
+			return '1=0';
+		}
+		$parts = [];
+		foreach ($def['kw'] as $k) {
+			$parts[] = "LOCATE('".self::sqlEsc($k)."', LOWER(t.description)) > 0";
+		}
+		$cond = '('.implode(' OR ', $parts).')';
+		if ($key === 'core_link') {
+			$cp = [];
+			foreach (($def['core'] ?? []) as $t) {
+				$cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0";
+			}
+			$cond .= ' AND ('.implode(' OR ', $cp).')';
+		}
+		elseif ($key === 'interface_down') {
+			$cp = [];
+			foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) {
+				$cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0";
+			}
+			$cond .= ' AND NOT ('.implode(' OR ', $cp).')';
+		}
+		return $cond;
+	}
+
+	private static function scopeSql(string $tid): string {
+		return 'EXISTS (SELECT 1 FROM functions f JOIN items i ON i.itemid=f.itemid '
+			.'JOIN hosts_templates ht ON ht.hostid=i.hostid '
+			.'WHERE f.triggerid=t.triggerid AND ht.templateid='.(int) $tid.')';
+	}
+
+	/** Dry-run: per-class {count, changes, target} over hosted (discovered) triggers. */
+	public static function previewStrategy(string $tid, array $targets): array {
+		$out = ['total' => 0, 'changes' => 0, 'classes' => []];
+		$scope = self::scopeSql($tid);
+		foreach ($targets as $key => $target) {
+			if ($key === 'other') {
+				continue;
+			}
+			$cond = self::classCondSql($key);
+			$row = DBfetch(DBselect(
+				'SELECT COUNT(*) AS c, SUM(CASE WHEN t.priority<>'.(int) $target.' THEN 1 ELSE 0 END) AS ch '
+				.'FROM triggers t WHERE t.flags=4 AND '.$scope.' AND '.$cond
+			));
+			$c = $row ? (int) $row['c'] : 0;
+			$ch = $row ? (int) $row['ch'] : 0;
+			$out['classes'][$key] = ['count' => $c, 'changes' => $ch, 'target' => (int) $target];
+			$out['total'] += $c;
+			$out['changes'] += $ch;
+		}
+		return $out;
+	}
+
+	/**
+	 * Apply a strategy:
+	 *  - template triggers  -> API trigger.update (instant propagation)
+	 *  - trigger prototypes -> API triggerprototype.update (future re-discovery)
+	 *  - discovered triggers -> SQL priority update (takes effect immediately)
+	 */
+	public static function applyStrategy(string $tid, array $targets): array {
+		$res = ['trigger' => 0, 'prototype' => 0, 'discovered' => 0];
+
+		$trs = API::Trigger()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]);
+		foreach ($trs as $t) {
+			$c = self::classifyRaw((string) $t['description']);
+			if (!isset($targets[$c]) || $c === 'other') {
+				continue;
+			}
+			$tg = (int) $targets[$c];
+			if ((int) $t['priority'] !== $tg) {
+				API::Trigger()->update(['triggerid' => $t['triggerid'], 'priority' => $tg]);
+				$res['trigger']++;
+			}
+		}
+
+		$prs = API::TriggerPrototype()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]);
+		foreach ($prs as $p) {
+			$c = self::classifyRaw((string) $p['description']);
+			if (!isset($targets[$c]) || $c === 'other') {
+				continue;
+			}
+			$tg = (int) $targets[$c];
+			if ((int) $p['priority'] !== $tg) {
+				API::TriggerPrototype()->update(['triggerid' => $p['triggerid'], 'priority' => $tg]);
+				$res['prototype']++;
+			}
+		}
+
+		// Discovered triggers: low priority classes first so higher priority wins on overlap.
+		$order = ['perf', 'interface_down', 'core_link', 'restarted', 'hw_fault', 'device_down'];
+		$scope = self::scopeSql($tid);
+		foreach ($order as $key) {
+			if (!isset($targets[$key])) {
+				continue;
+			}
+			$tg = (int) $targets[$key];
+			$cond = self::classCondSql($key);
+			$cnt = DBfetch(DBselect(
+				'SELECT COUNT(*) AS c FROM triggers t WHERE t.flags=4 AND '.$scope
+				.' AND '.$cond.' AND t.priority<>'.$tg
+			));
+			$n = $cnt ? (int) $cnt['c'] : 0;
+			if ($n > 0) {
+				$ok = DBexecute('UPDATE triggers AS t SET t.priority='.$tg
+					.' WHERE t.flags=4 AND '.$scope.' AND '.$cond.' AND t.priority<>'.$tg);
+				if ($ok === false) {
+					throw new Exception('DB update failed for class '.$key);
+				}
+				$res['discovered'] += $n;
+			}
+		}
+		return $res;
+	}
+
+	/* ================================================================== */
+	/*  SERVER TUNING GENERATOR (Zabbix server process / cache settings)  */
+	/*  Produces a copy-paste env block + restart command. Nothing is     */
+	/*  written here - the operator applies it on the host (zero risk).   */
+	/* ================================================================== */
+
+	public const TUNING_PROFILES = [
+		'small' => [
+			'StartSNMPPollers' => 10, 'StartPollers' => 10, 'StartLLDWorkers' => 5,
+			'StartPreprocessors' => 3, 'CacheSize' => '256M', 'ValueCacheSize' => '64M',
+			'HistoryCacheSize' => '32M', 'HistoryIndexCacheSize' => '8M', 'TrendCacheSize' => '32M'
+		],
+		'medium' => [
+			'StartSNMPPollers' => 30, 'StartPollers' => 20, 'StartLLDWorkers' => 10,
+			'StartPreprocessors' => 5, 'CacheSize' => '512M', 'ValueCacheSize' => '128M',
+			'HistoryCacheSize' => '64M', 'HistoryIndexCacheSize' => '16M', 'TrendCacheSize' => '64M'
+		],
+		'large' => [
+			'StartSNMPPollers' => 60, 'StartPollers' => 40, 'StartLLDWorkers' => 20,
+			'StartPreprocessors' => 10, 'CacheSize' => '1G', 'ValueCacheSize' => '256M',
+			'HistoryCacheSize' => '128M', 'HistoryIndexCacheSize' => '32M', 'TrendCacheSize' => '128M'
+		]
+	];
+
+	/**
+	 * Build a compose .env / environment snippet for the chosen profile, plus
+	 * the command to recreate the server container.
+	 * @return array{env:string, cmd:string}
+	 */
+	public static function tuningSnippet(string $profile): array {
+		$vals = self::TUNING_PROFILES[$profile] ?? self::TUNING_PROFILES['medium'];
+		$lines = [];
+		foreach ($vals as $k => $v) {
+			$lines[] = 'ZBX_'.$k.'='.$v;
+		}
+		$env = implode("\n", $lines);
+		$cmd = "# paste the ZBX_* lines into /opt/zabbix/.env (or docker-compose environment)\n"
+			."cd /opt/zabbix && docker compose up -d zabbix-server";
+		return ['env' => $env, 'cmd' => $cmd];
+	}
 }

+ 86 - 22
web-modules/WecomCenter/locale/en.php

@@ -3,56 +3,120 @@
 return [
 	'menu.title'      => 'WeCom Center',
 	'page.title'      => 'WeCom Center',
-	'tab.alert'       => 'Alert Optimization',
-	'tab.robot'       => 'Robot & Account',
-	'tab.templates'   => 'Template Import',
-	'tab.map'         => 'Map Coordinates',
+	'tab.alert'       => 'Alert',
+	'tab.opt'         => 'Optimization',
+	'tab.maint'       => 'Maintenance',
+
+	// section headings
+	'sec.robot'       => 'WeCom group robot',
+	'sec.strategy'    => 'Alert strategy (pick one, then Apply)',
+	'sec.threshold'   => 'Manual threshold (overrides the strategy)',
+	'sec.tuning'      => 'Server tuning generator',
+	'sec.template'    => 'Template import',
+	'sec.map'         => 'Map marker',
+	'sec.triggers'    => 'Per-trigger alerting switch',
+
+	// media type
 	'lbl.mediatype'   => 'Alert media type',
 	'lbl.alerting_on' => 'Alerting enabled',
 	'lbl.severity'    => 'Severities that will notify',
-	'lbl.sendto'      => 'Recipient (send to)',
-	'lbl.scheme_hint' => 'One-click schemes (applied immediately):',
 	'lbl.key'         => 'Robot key (global macro {$WECOM_KEY})',
-	'lbl.account'     => 'Robot account (username)',
+	'lbl.strategy_hint'  => 'Every strategy pins the "must alert" classes to the highest severity (Disaster) so they always fire, and sets a threshold for the rest. Apply is safe and repeatable.',
+	'lbl.threshold_hint' => 'The strategy sets this automatically. Change it here only for a one-off adjustment.',
+	'lbl.tuning_hint'    => 'This only GENERATES the settings and command — nothing is changed. Copy them onto the Zabbix host and recreate the server container yourself.',
+
+	// buttons
 	'btn.save'        => 'Save',
 	'btn.test'        => 'Send test',
 	'btn.import'      => 'Import template',
+	'btn.preview'     => 'Preview',
+	'btn.apply'       => 'Apply',
+	'btn.generate'    => 'Generate',
+	'btn.search'      => 'Search',
+
+	// severities
 	'sev.1'           => 'Not classified',
 	'sev.2'           => 'Information',
 	'sev.3'           => 'Warning',
 	'sev.4'           => 'Average',
 	'sev.5'           => 'High',
 	'sev.6'           => 'Disaster',
-	'scheme.standard' => 'Standard (High + Disaster)',
-	'scheme.warnplus' => 'Warning Plus (Average+)',
-	'scheme.all'      => 'All severities',
-	'scheme.quiet'    => 'Quiet (Disaster only)',
-	'scheme.off'      => 'Off (mute all)',
+
+	// trigger classes
+	'cls.device_down'    => 'Device down / unreachable',
+	'cls.hw_fault'       => 'Hardware fault (fan/power/temperature)',
+	'cls.restarted'      => 'Device restarted',
+	'cls.core_link'      => 'Core/uplink link down',
+	'cls.interface_down' => 'Access interface down',
+	'cls.perf'           => 'Performance (bandwidth/error/CPU)',
+	'cls.other'          => 'Other (unchanged)',
+	'cls.col.class'      => 'Class',
+	'cls.col.target'     => 'Severity',
+	'cls.col.count'      => 'Triggers',
+	'cls.col.changes'    => 'Will change',
+	'cls.total'          => 'Total',
+
+	// strategy presets
+	'preset.current'      => '(current)',
+	'preset.col.name'     => 'Strategy',
+	'preset.col.desc'     => 'Description',
+	'preset.col.action'   => 'Actions',
+	'preset.steady'       => 'Steady operations',
+	'preset.steady.desc'  => 'Must-alert always fires; rest only High+. Balanced for daily production.',
+	'preset.core'         => 'Core essentials only',
+	'preset.core.desc'    => 'Only must-alert classes fire (Disaster). Everything else muted — max noise reduction.',
+	'preset.observe'      => 'Full observation',
+	'preset.observe.desc' => 'Notify from Warning up. For onboarding / troubleshooting periods.',
+	'preset.night'        => 'Night quiet',
+	'preset.night.desc'   => 'Disaster only. For night shifts / on-call quiet hours.',
+	'preset.balanced'     => 'Core strict / access loose',
+	'preset.balanced.desc'=> 'Must-alert + Average up. Slightly broader than steady.',
+
+	// preview
+	'preview.title'   => 'Preview — dry run for "{1}" (nothing written)',
+	'preview.hint'    => 'These are the hosted triggers that would change. Apply to actually write them.',
+
+	// templates
 	'tpl.choose'      => 'Bundled template',
 	'tpl.none'        => 'No bundled template found',
+
+	// map
 	'map.choose_host' => '-- select host --',
-	'map.host'        => 'Host',
+	'map.host'        => 'Marker host',
 	'map.lat'         => 'Latitude',
 	'map.lon'         => 'Longitude',
+	'map.hint'        => 'The map uses one representative point (the Zabbix server). Note: the Geomap widget needs the browser to reach the internet for base-map tiles.',
+
+	// tuning
+	'tuning.choose'   => 'Hardware profile',
+	'tuning.small'    => 'Small (< 200 hosts)',
+	'tuning.medium'   => 'Medium (~ 500 hosts)',
+	'tuning.large'    => 'Large (1000+ hosts)',
+	'tuning.env'      => 'Settings (paste into .env / compose environment):',
+	'tuning.cmd'      => 'Then apply:',
+
+	// triggers / maintenance
+	'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).',
+
+	// messages
 	'msg.saved'       => 'Alert settings saved.',
-	'msg.robot_saved' => 'Robot / key saved.',
+	'msg.robot_saved' => 'Robot key saved.',
 	'msg.tested'      => 'Test message sent successfully (errcode 0).',
 	'msg.test_failed' => 'Test failed (HTTP {1}): {2}',
 	'msg.imported'    => 'Template imported. Templates: {1}',
 	'msg.map_saved'   => 'Map coordinates saved.',
-	'msg.scheme_applied' => 'Scheme applied: {1}',
-	'msg.no_mediatype' => 'No webhook media type found.',
+	'msg.no_mediatype'=> 'No webhook media type found.',
 	'msg.no_url'      => 'Robot key is empty (set {$WECOM_KEY}).',
 	'msg.no_template' => 'Please choose a template.',
 	'msg.no_host'     => 'Please choose a host.',
-	'msg.bad_scheme'  => 'Unknown scheme.',
+	'msg.no_template_host' => 'Huawei walk template not found on this server.',
+	'msg.no_server_host'   => 'Host "Zabbix server" not found.',
+	'msg.bad_scheme'  => 'Unknown strategy.',
 	'msg.unknown_op'  => 'Unknown operation.',
-	'tab.triggers'    => 'Trigger Alerting Switch',
-	'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).',
-	'btn.search'      => 'Search',
 	'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.strategy_applied' => 'Strategy "{1}" applied — template triggers: {2}, prototypes: {3}, discovered triggers: {4}.',
 ];

+ 80 - 28
web-modules/WecomCenter/locale/zh_CN.php

@@ -1,58 +1,110 @@
 <?php
 // WeCom Center - 简体中文
 return [
-	'menu.title'      => '企微告警中心',
-	'page.title'      => '企微告警中心',
-	'tab.alert'       => '告警优化',
-	'tab.robot'       => '机器人与账号',
-	'tab.templates'   => '模板导入',
-	'tab.map'         => '地图坐标',
-	'lbl.mediatype'   => '告警媒介类型',
+	'menu.title'      => '企业微信告警中心',
+	'page.title'      => '企业微信告警中心',
+	'tab.alert'       => '告警',
+	'tab.opt'         => '优化',
+	'tab.maint'       => '日常维护',
+
+	'sec.robot'       => '企业微信群机器人',
+	'sec.strategy'    => '告警策略(先选,再应用)',
+	'sec.threshold'   => '手动阈值(覆盖策略设置)',
+	'sec.tuning'      => '服务端调优生成器',
+	'sec.template'    => '模板导入',
+	'sec.map'         => '地图标记',
+	'sec.triggers'    => '逐条触发告警开关',
+
+	'lbl.mediatype'   => '告警媒介',
 	'lbl.alerting_on' => '启用告警',
-	'lbl.severity'    => '触发通知的严重度',
-	'lbl.sendto'      => '接收目标(sendto)',
-	'lbl.scheme_hint' => '一键方案(立即生效):',
+	'lbl.severity'    => '会通知的严重度',
 	'lbl.key'         => '机器人 key(全局宏 {$WECOM_KEY})',
-	'lbl.account'     => '机器人账号(用户名)',
+	'lbl.strategy_hint'  => '每套策略都会把“必告警”类别固定为最高严重度(灾难),确保无论如何都会发;其余级别按阈值决定。应用可重复执行,安全。',
+	'lbl.threshold_hint' => '策略会自动设置此项。仅在需要临时微调时手动改。',
+	'lbl.tuning_hint'    => '这里只“生成”参数和命令,不会改任何东西。请复制到 Zabbix 主机自行重建服务端容器。',
+
 	'btn.save'        => '保存',
 	'btn.test'        => '发送测试',
 	'btn.import'      => '导入模板',
+	'btn.preview'     => '预览',
+	'btn.apply'       => '应用',
+	'btn.generate'    => '生成',
+	'btn.search'      => '搜索',
+
 	'sev.1'           => '未分类',
 	'sev.2'           => '信息',
 	'sev.3'           => '警告',
 	'sev.4'           => '一般严重',
 	'sev.5'           => '严重',
 	'sev.6'           => '灾难',
-	'scheme.standard' => '标准(严重+灾难)',
-	'scheme.warnplus' => '警告以上(一般严重及以上)',
-	'scheme.all'      => '全部严重度',
-	'scheme.quiet'    => '安静(仅灾难)',
-	'scheme.off'      => '关闭(全部静音)',
+
+	'cls.device_down'    => '设备离线 / 不可达',
+	'cls.hw_fault'       => '硬件故障(风扇/电源/温度)',
+	'cls.restarted'      => '设备重启',
+	'cls.core_link'      => '核心/上联链路中断',
+	'cls.interface_down' => '接入口 Down',
+	'cls.perf'           => '性能(带宽/错包/CPU)',
+	'cls.other'          => '其它(不改)',
+	'cls.col.class'      => '类别',
+	'cls.col.target'     => '严重度',
+	'cls.col.count'      => '触发器数',
+	'cls.col.changes'    => '将改动',
+	'cls.total'          => '合计',
+
+	'preset.current'      => '(当前)',
+	'preset.col.name'     => '策略',
+	'preset.col.desc'     => '说明',
+	'preset.col.action'   => '操作',
+	'preset.steady'       => '稳健运维',
+	'preset.steady.desc'  => '必告警始终发;其余仅严重以上。适合日常生产。',
+	'preset.core'         => '只保命(极简)',
+	'preset.core.desc'    => '只发必告警类别(灾难级),其余静默——最大降噪。',
+	'preset.observe'      => '全量观察',
+	'preset.observe.desc' => '从“警告”起就通知。适合新接入/排障期。',
+	'preset.night'        => '夜间降噪',
+	'preset.night.desc'   => '仅灾难级。适合夜班/值班静默时段。',
+	'preset.balanced'     => '核心从严/接入从宽',
+	'preset.balanced.desc'=> '必告警 + 一般严重以上。比稳健运维略宽。',
+
+	'preview.title'   => '预览 —— “{1}”的 dry-run(不会写入)',
+	'preview.hint'    => '以下是将会改动的设备触发器。点“应用”才真正写入。',
+
 	'tpl.choose'      => '内置模板',
 	'tpl.none'        => '未找到内置模板',
+
 	'map.choose_host' => '-- 选择主机 --',
-	'map.host'        => '主机',
+	'map.host'        => '标记主机',
 	'map.lat'         => '纬度',
 	'map.lon'         => '经度',
+	'map.hint'        => '地图使用单一代表点(Zabbix server)。注意:Geomap 组件需要浏览器能访问外网加载底图。',
+
+	'tuning.choose'   => '规模档位',
+	'tuning.small'    => '小型(< 200 台)',
+	'tuning.medium'   => '中型(约 500 台)',
+	'tuning.large'    => '大型(1000+ 台)',
+	'tuning.env'      => '参数(粘贴到 .env / compose 环境):',
+	'tuning.cmd'      => '然后执行:',
+
+	'lbl.trig_search' => '按触发器描述搜索(如 CPU、磁盘)',
+	'lbl.trig_list'   => '触发器(勾选 = 启用告警)',
+	'lbl.trig_hint'   => '共有 {1} 个触发器。在上方输入关键词搜索(最多显示 200 条)。',
+
 	'msg.saved'       => '告警设置已保存。',
-	'msg.robot_saved' => '机器人/ key 已保存。',
+	'msg.robot_saved' => '机器人 key 已保存。',
 	'msg.tested'      => '测试消息发送成功(errcode 0)。',
 	'msg.test_failed' => '测试失败(HTTP {1}):{2}',
 	'msg.imported'    => '模板已导入。模板数:{1}',
 	'msg.map_saved'   => '地图坐标已保存。',
-	'msg.scheme_applied' => '已应用方案:{1}',
-	'msg.no_mediatype' => '未找到 webhook 媒介类型。',
+	'msg.no_mediatype'=> '未找到 Webhook 媒介。',
 	'msg.no_url'      => '机器人 key 为空(请设置 {$WECOM_KEY})。',
 	'msg.no_template' => '请选择一个模板。',
-	'msg.no_host'     => '请选择一台主机。',
-	'msg.bad_scheme'  => '未知方案。',
+	'msg.no_host'     => '请选择主机。',
+	'msg.no_template_host' => '未找到“Huawei VRP by SNMP walk”模板。',
+	'msg.no_server_host'   => '未找到“Zabbix server”主机。',
+	'msg.bad_scheme'  => '未知策略。',
 	'msg.unknown_op'  => '未知操作。',
-	'tab.triggers'    => '触发器告警开关',
-	'lbl.trig_search' => '按触发器名称搜索(如 CPU、磁盘)',
-	'lbl.trig_list'   => '触发器列表(勾选 = 启用告警)',
-	'lbl.trig_hint'   => '共 {1} 个触发器。请输入关键字搜索(最多显示 200 条)。',
-	'btn.search'      => '搜索',
-	'msg.no_triggers' => '未选择任何触发器。',
+	'msg.no_triggers' => '未选择触发器。',
 	'msg.triggers_saved' => '触发器告警已更新:{1} 个。',
 	'msg.no_trig_search' => '没有匹配的触发器。',
+	'msg.strategy_applied' => '策略“{1}”已应用 —— 模板触发器:{2},触发器原型:{3},发现触发器:{4}。',
 ];

+ 173 - 179
web-modules/WecomCenter/views/wecom.center.view.php

@@ -1,6 +1,6 @@
 <?php declare(strict_types = 0);
 /*
-** WeCom Center - page view.
+** WeCom Center - page view (three tabs: Alert / Optimization / Maintenance).
 ** @var CView $this
 ** @var array  $data
 */
@@ -10,38 +10,10 @@ use Modules\WecomCenter\Lib\I18n;
 $this->addCssFile('modules/WecomCenter/assets/wecom.center.css');
 
 $save_url = (new CUrl('zabbix.php'))->setArgument('action', 'wecom.center.save')->getUrl();
+$view_url = (new CUrl('zabbix.php'))->setArgument('action', 'wecom.center.view')->getUrl();
 
 $html_page = (new CHtmlPage())->setTitle(I18n::t('page.title'));
 
-/* ------------------------------------------------------------------ */
-/*  Media-type switcher (only when several webhook media types exist)  */
-/* ------------------------------------------------------------------ */
-if (count($data['mediatypes']) > 1) {
-	$list = new CList();
-	foreach ($data['mediatypes'] as $mt) {
-		$label = $mt['name'];
-		if ((string) $mt['mediatypeid'] === (string) $data['mediatypeid']) {
-			$list->addItem(new CSpan('['.$label.']'));
-		}
-		else {
-			$list->addItem(new CLink($label,
-				(new CUrl('zabbix.php'))
-					->setArgument('action', 'wecom.center.view')
-					->setArgument('mediatypeid', $mt['mediatypeid'])
-					->getUrl()
-			));
-		}
-	}
-	$html_page->addItem((new CDiv())->addItem(new CTag('h3'))->addItem(I18n::t('lbl.mediatype'))->addItem($list));
-}
-elseif ($data['mediatype']) {
-	$html_page->addItem(
-		(new CDiv())
-			->addItem((new CTag('h3'))->addItem(I18n::t('lbl.mediatype')))
-			->addItem(new CSpan($data['mediatype']['name']))
-	);
-}
-
 if (!$data['mediatype']) {
 	$html_page->addItem((new CDiv())->addItem(
 		(new CTag('p'))->addItem(I18n::t('msg.no_mediatype'))
@@ -53,177 +25,206 @@ if (!$data['mediatype']) {
 $mask = (int) $data['severity'];
 $enabled = ((int) $data['active'] === 0);
 
-/* ------------------------------------------------------------------ */
-/*  1) Alert optimization (the core: simple switches + presets)        */
-/* ------------------------------------------------------------------ */
-$sev_items = new CList();
-for ($i = 1; $i <= 6; $i++) {
-	$bit = 1 << ($i - 1);
-	$sev_items->addItem(
-		(new CCheckBox('sev_'.$i))
-			->setLabel(I18n::t('sev.'.$i))
-			->setChecked(($mask & $bit) !== 0)
+$tabs = new CTabView();
+
+/* ================================================================== */
+/*  TAB 1 - ALERT (robot + strategy engine + manual threshold)        */
+/* ================================================================== */
+$alert = new CDiv();
+
+// --- 1a. Robot key -----------------------------------------------------
+$robot_form = (new CForm())
+	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
+	->addItem(
+		(new CFormList())->addRow(I18n::t('lbl.key'),
+			(new CTextBox('webhook_url', $data['webhook_url']))
+				->setWidth(ZBX_TEXTAREA_BIG_WIDTH)
+				->setAttribute('style', 'font-family:monospace;')
+		)
+	)
+	->addItem(
+		(new CDiv())
+			->addItem((new CSubmitButton(I18n::t('btn.save'), 'op', 'save_robot')))
+			->addItem((new CSubmitButton(I18n::t('btn.test'), 'op', 'test_robot'))->addClass(ZBX_STYLE_BTN_ALT))
+	)
+	->setAction($save_url)
+	->setId('wc-robot');
+
+$alert->addItem((new CTag('h4'))->addItem(I18n::t('sec.robot')));
+$alert->addItem($robot_form);
+
+// --- 1b. Strategy presets ---------------------------------------------
+$preset_tbl = new CTable();
+$preset_tbl->setHeader([I18n::t('preset.col.name'), I18n::t('preset.col.desc'), I18n::t('preset.col.action')]);
+foreach ($data['presets'] as $key => $p) {
+	$is_cur = ((string) $data['current_strategy'] === (string) $key);
+	$name_cell = new CDiv();
+	$name_cell->addItem(I18n::t($p['label']));
+	if ($is_cur) {
+		$name_cell->addItem((new CSpan(I18n::t('preset.current')))->addClass(ZBX_STYLE_GREEN));
+	}
+	$preview_link = new CLink(I18n::t('btn.preview'),
+		(new CUrl('zabbix.php'))
+			->setArgument('action', 'wecom.center.view')
+			->setArgument('mediatypeid', $data['mediatypeid'])
+			->setArgument('preview_preset', $key)
+			->getUrl()
 	);
+	$apply_btn = (new CSubmitButton(I18n::t('btn.apply'), 'apply_strategy', $key))
+		->addClass(ZBX_STYLE_BTN_ALT);
+	$preset_tbl->addRow([
+		$name_cell,
+		I18n::t($p['desc']),
+		(new CDiv())->addItem($preview_link)->addItem($apply_btn)
+	]);
 }
+$strategy_form = (new CForm())
+	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
+	->addItem(new CDiv($preset_tbl))
+	->setAction($save_url)
+	->setId('wc-strategy');
 
-$enabled_box = (new CCheckBox('enabled'))
-	->setLabel(I18n::t('lbl.alerting_on'))
-	->setChecked($enabled);
+$alert->addItem((new CTag('h4'))->addItem(I18n::t('sec.strategy')));
+$alert->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('lbl.strategy_hint')));
+$alert->addItem($strategy_form);
 
-$alert_list = (new CFormList())
-	->addRow(I18n::t('lbl.alerting_on'), $enabled_box)
-	->addRow(I18n::t('lbl.severity'), $sev_items)
-	->addRow(I18n::t('lbl.sendto'),
-		(new CTextBox('sendto', $data['sendto']))->setWidth(ZBX_TEXTAREA_STANDARD_WIDTH)
+// --- 1c. Preview result (dry-run) -------------------------------------
+if ($data['preview'] !== null) {
+	$pv = $data['preview'];
+	$pkey = $data['preview_key'];
+	$pt = new CTable();
+	$pt->setHeader([
+		I18n::t('cls.col.class'), I18n::t('cls.col.target'),
+		I18n::t('cls.col.count'), I18n::t('cls.col.changes')
+	]);
+	foreach ($pv['classes'] as $ck => $c) {
+		$label = I18n::t($data['classes'][$ck]['label'] ?? $ck);
+		$pt->addRow([$label, I18n::t('sev.'.($c['target'] + 1)), $c['count'], $c['changes']]);
+	}
+	$pt->addRow([(new CTag('b'))->addItem(I18n::t('cls.total')), '', (new CTag('b'))->addItem((string) $pv['total']), (new CTag('b'))->addItem((string) $pv['changes'])]);
+	$alert->addItem(
+		(new CDiv())
+			->addItem((new CTag('h4'))->addItem(
+				I18n::t('preview.title', [I18n::t($data['presets'][$pkey]['label'] ?? $pkey)])
+			))
+			->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('preview.hint')))
+			->addItem($pt)
 	);
-
-$preset_buttons = [];
-foreach ($data['schemes'] as $key => $def) {
-	$preset_buttons[] = (new CSubmitButton(I18n::t('scheme.'.$key), 'scheme_'.$key, '1'))
-		->addClass(ZBX_STYLE_BTN_ALT);
 }
 
-$alert_form = (new CForm())
+// --- 1d. Manual threshold override ------------------------------------
+$sev_items = new CList();
+for ($i = 1; $i <= 6; $i++) {
+	$bit = 1 << ($i - 1);
+	$sev_items->addItem(
+		(new CCheckBox('sev_'.$i))->setLabel(I18n::t('sev.'.$i))->setChecked(($mask & $bit) !== 0)
+	);
+}
+$sev_form = (new CForm())
 	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
-	->addItem((new CVar('username', $data['robot_name']))->removeId())
 	->addItem(
-		(new CTabView())->addTab('alert', I18n::t('tab.alert'), $alert_list)
-			->setFooter(makeFormFooter(
-				(new CSubmitButton(I18n::t('btn.save'), 'op', 'save_alert'))->setId('alert-save'),
-				[]
-			))
+		(new CFormList())
+			->addRow(I18n::t('lbl.alerting_on'),
+				(new CCheckBox('enabled'))->setLabel(I18n::t('lbl.alerting_on'))->setChecked($enabled))
+			->addRow(I18n::t('lbl.severity'), $sev_items)
 	)
+	->addItem((new CSubmitButton(I18n::t('btn.save'), 'op', 'save_alert')))
 	->setAction($save_url)
-	->setId('wecom-alert-form');
+	->setId('wc-sev');
 
-// Preset form (separate buttons outside footer of alert form)
-$preset_form = (new CForm())
-	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
-	->addItem((new CVar('username', $data['robot_name']))->removeId())
-	->addItem(new CDiv($preset_buttons))
-	->setAction($save_url)
-	->setId('wecom-preset-form');
+$alert->addItem((new CTag('h4'))->addItem(I18n::t('sec.threshold')));
+$alert->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('lbl.threshold_hint')));
+$alert->addItem($sev_form);
 
-$html_page->addItem(
-	(new CDiv())
-		->addItem((new CTag('h3'))->addItem(I18n::t('tab.alert')))
-		->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('lbl.scheme_hint')))
-		->addItem($preset_form)
-		->addItem($alert_form)
-);
+$tabs->addTab('alert', I18n::t('tab.alert'), $alert);
 
-/* ------------------------------------------------------------------ */
-/*  2) Robot / account / key                                          */
-/* ------------------------------------------------------------------ */
-$robot_list = (new CFormList())
-	->addRow(I18n::t('lbl.key'),
-		(new CTextBox('webhook_url', $data['webhook_url']))
-			->setWidth(ZBX_TEXTAREA_BIG_WIDTH)
-			->setAttribute('style', 'font-family:monospace;'))
-	->addRow(I18n::t('lbl.account'),
-		(new CTextBox('username', $data['robot_name']))->setWidth(ZBX_TEXTAREA_STANDARD_WIDTH))
-	->addRow(I18n::t('lbl.sendto'),
-		(new CTextBox('sendto', $data['sendto']))->setWidth(ZBX_TEXTAREA_STANDARD_WIDTH));
+/* ================================================================== */
+/*  TAB 2 - OPTIMIZATION                                               */
+/* ================================================================== */
+$opt = new CDiv();
 
-$robot_form = (new CForm())
-	->addItem((new CVar('mediatypeid', $data['mediatypeid']))->removeId())
+// --- 2a. Server tuning generator --------------------------------------
+$tune_sel = new CSelect('tuning_profile');
+foreach (['small', 'medium', 'large'] as $pk) {
+	$tune_sel->addOption(new CSelectOption($pk, I18n::t('tuning.'.$pk)));
+}
+$tune_sel->setValue($data['tuning_profile']);
+$tune_form = (new CForm('get', $view_url))
 	->addItem(
-		(new CTabView())->addTab('robot', I18n::t('tab.robot'), $robot_list)
-			->setFooter(makeFormFooter(
-				(new CSubmitButton(I18n::t('btn.save'), 'op', 'save_robot'))->setId('robot-save'),
-				[(new CSubmitButton(I18n::t('btn.test'), 'op', 'test_robot'))->addClass(ZBX_STYLE_BTN_ALT)]
-			))
+		(new CFormList())->addRow(I18n::t('tuning.choose'), $tune_sel)
 	)
-	->setAction($save_url)
-	->setId('wecom-robot-form');
+	->addItem((new CSubmitButton(I18n::t('btn.generate'), 'generate', '1'))->addClass(ZBX_STYLE_BTN_ALT))
+	->setId('wc-tune');
 
-$html_page->addItem(
-	(new CDiv())
-		->addItem((new CTag('h3'))->addItem(I18n::t('tab.robot')))
-		->addItem($robot_form)
-);
+$env_pre = (new CTag('pre'))->addItem($data['tuning']['env'])
+	->setAttribute('style', 'background:#f4f4f4;padding:8px;border:1px solid #ccc;font-family:monospace;');
+$cmd_pre = (new CTag('pre'))->addItem($data['tuning']['cmd'])
+	->setAttribute('style', 'background:#f4f4f4;padding:8px;border:1px solid #ccc;font-family:monospace;');
 
-/* ------------------------------------------------------------------ */
-/*  3) One-click template import                                      */
-/* ------------------------------------------------------------------ */
-$tpl_select = new CSelect('template_file');
+$opt->addItem((new CTag('h4'))->addItem(I18n::t('sec.tuning')));
+$opt->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('lbl.tuning_hint')));
+$opt->addItem($tune_form);
+$opt->addItem((new CDiv())->addItem((new CTag('strong'))->addItem(I18n::t('tuning.env')))->addItem($env_pre));
+$opt->addItem((new CDiv())->addItem((new CTag('strong'))->addItem(I18n::t('tuning.cmd')))->addItem($cmd_pre));
+
+// --- 2b. Template import ---------------------------------------------
+$tpl_sel = new CSelect('template_file');
 if (!$data['templates']) {
-	$tpl_select->addOption(new CSelectOption('', I18n::t('tpl.none')));
+	$tpl_sel->addOption(new CSelectOption('', I18n::t('tpl.none')));
 }
 foreach ($data['templates'] as $t) {
-	$tpl_select->addOption(new CSelectOption($t['file'], $t['name']));
+	$tpl_sel->addOption(new CSelectOption($t['file'], $t['name']));
 }
-
-$tpl_list = (new CFormList())->addRow(I18n::t('tpl.choose'), $tpl_select);
-
 $tpl_form = (new CForm())
-	->addItem(
-		(new CTabView())->addTab('tpl', I18n::t('tab.templates'), $tpl_list)
-			->setFooter(makeFormFooter(
-				(new CSubmitButton(I18n::t('btn.import'), 'op', 'import_template'))->setId('tpl-import'),
-				[]
-			))
-	)
+	->addItem((new CFormList())->addRow(I18n::t('tpl.choose'), $tpl_sel))
+	->addItem((new CSubmitButton(I18n::t('btn.import'), 'op', 'import_template')))
 	->setAction($save_url)
-	->setId('wecom-tpl-form');
-
-$html_page->addItem(
-	(new CDiv())
-		->addItem((new CTag('h3'))->addItem(I18n::t('tab.templates')))
-		->addItem($tpl_form)
-);
+	->setId('wc-tpl');
+$opt->addItem((new CTag('h4'))->addItem(I18n::t('sec.template')));
+$opt->addItem($tpl_form);
 
-/* ------------------------------------------------------------------ */
-/*  4) Map coordinates                                                */
-/* ------------------------------------------------------------------ */
-$host_select = new CSelect('hostid');
-$host_select->addOption(new CSelectOption('', I18n::t('map.choose_host')));
-foreach ($data['hosts'] as $h) {
-	$host_select->addOption(new CSelectOption($h['hostid'], $h['name']));
+// --- 2c. Map marker (single point = Zabbix server host) --------------
+if ($data['server_host']) {
+	$map_form = (new CForm())
+		->addItem((new CVar('hostid', $data['server_host']['hostid']))->removeId())
+		->addItem(
+			(new CFormList())
+				->addRow(I18n::t('map.host'),
+					(new CSpan($data['server_host']['name'])))
+				->addRow(I18n::t('map.lat'),
+					(new CTextBox('lat', $data['server_loc']['location_lat']))->setWidth(ZBX_TEXTAREA_SMALL_WIDTH))
+				->addRow(I18n::t('map.lon'),
+					(new CTextBox('lon', $data['server_loc']['location_lon']))->setWidth(ZBX_TEXTAREA_SMALL_WIDTH))
+		)
+		->addItem((new CSubmitButton(I18n::t('btn.save'), 'op', 'save_map')))
+		->setAction($save_url)
+		->setId('wc-map');
+	$opt->addItem((new CTag('h4'))->addItem(I18n::t('sec.map')));
+	$opt->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('map.hint')));
+	$opt->addItem($map_form);
 }
-if ($data['selected_hostid'] !== '') {
-	$host_select->setValue($data['selected_hostid']);
+else {
+	$opt->addItem((new CTag('h4'))->addItem(I18n::t('sec.map')));
+	$opt->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('msg.no_server_host')));
 }
 
-$map_list = (new CFormList())
-	->addRow(I18n::t('map.host'), $host_select)
-	->addRow(I18n::t('map.lat'),
-		(new CTextBox('lat', $data['host_loc']['location_lat']))->setWidth(ZBX_TEXTAREA_SMALL_WIDTH))
-	->addRow(I18n::t('map.lon'),
-		(new CTextBox('lon', $data['host_loc']['location_lon']))->setWidth(ZBX_TEXTAREA_SMALL_WIDTH));
-
-$map_form = (new CForm())
-	->addItem(
-		(new CTabView())->addTab('map', I18n::t('tab.map'), $map_list)
-			->setFooter(makeFormFooter(
-				(new CSubmitButton(I18n::t('btn.save'), 'op', 'save_map'))->setId('map-save'),
-				[]
-			))
-	)
-	->setAction($save_url)
-	->setId('wecom-map-form');
+$tabs->addTab('opt', I18n::t('tab.opt'), $opt);
 
-$html_page->addItem(
-	(new CDiv())
-		->addItem((new CTag('h3'))->addItem(I18n::t('tab.map')))
-		->addItem($map_form)
-);
+/* ================================================================== */
+/*  TAB 3 - MAINTENANCE (per-trigger alerting switch)                 */
+/* ================================================================== */
+$maint = new CDiv();
 
-/* ------------------------------------------------------------------ */
-/*  5) Per-trigger alerting switch (search + checkboxes)              */
-/* ------------------------------------------------------------------ */
-$trig_search_form = (new CForm('get',
-		(new CUrl('zabbix.php'))->setArgument('action', 'wecom.center.view')->getUrl()))
+$trig_search_form = (new CForm('get', $view_url))
 	->addItem((new CTextBox('trig_search', $data['trig_search']))
 		->setWidth(ZBX_TEXTAREA_STANDARD_WIDTH)
 		->setAttribute('placeholder', I18n::t('lbl.trig_search')))
 	->addItem((new CSubmitButton(I18n::t('btn.search'), 'search', '1'))->addClass(ZBX_STYLE_BTN_ALT))
 	->setId('trig-search-form');
 
-$trig_div = (new CDiv())
-	->addItem((new CTag('h3'))->addItem(I18n::t('tab.triggers')))
-	->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('lbl.trig_hint', [$data['trigger_count']])))
-	->addItem($trig_search_form);
+$maint->addItem((new CTag('h4'))->addItem(I18n::t('sec.triggers')));
+$maint->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('lbl.trig_hint', [$data['trigger_count']])));
+$maint->addItem($trig_search_form);
 
 if ($data['trig_search'] !== '') {
 	if ($data['triggers']) {
@@ -235,30 +236,23 @@ if ($data['trig_search'] !== '') {
 			$sev_name = I18n::t('sev.'.(((int) $t['priority']) + 1));
 			$label = '['.$sev_name.'] '.$host.' — '.$t['description'];
 			$trig_list->addItem(
-				(new CCheckBox('trig_'.$t['triggerid']))
-					->setLabel($label)
-					->setChecked(((int) $t['status']) === 0)
+				(new CCheckBox('trig_'.$t['triggerid']))->setLabel($label)->setChecked(((int) $t['status']) === 0)
 			);
 		}
-		$trig_list_wrap = (new CFormList())->addRow(I18n::t('lbl.trig_list'), $trig_list);
 		$trig_save_form = (new CForm())
 			->addItem((new CVar('trig_ids', implode(',', $ids)))->removeId())
-			->addItem(
-				(new CTabView())->addTab('trig', I18n::t('tab.triggers'), $trig_list_wrap)
-					->setFooter(makeFormFooter(
-						(new CSubmitButton(I18n::t('btn.save'), 'op', 'save_triggers'))->setId('trig-save'),
-						[]
-					))
-			)
+			->addItem((new CFormList())->addRow(I18n::t('lbl.trig_list'), $trig_list))
+			->addItem((new CSubmitButton(I18n::t('btn.save'), 'op', 'save_triggers')))
 			->setAction($save_url)
-			->setId('wecom-trig-form');
-		$trig_div->addItem($trig_save_form);
+			->setId('wc-trig');
+		$maint->addItem($trig_save_form);
 	}
 	else {
-		$trig_div->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('msg.no_trig_search')));
+		$maint->addItem(new CDiv(new CTag('p'))->addItem(I18n::t('msg.no_trig_search')));
 	}
 }
 
-$html_page->addItem($trig_div);
+$tabs->addTab('maint', I18n::t('tab.maint'), $maint);
 
+$html_page->addItem($tabs);
 $html_page->show();