Helper.php 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835
  1. <?php declare(strict_types = 0);
  2. /*
  3. ** WeCom Center - business logic helpers.
  4. ** Works through the official Zabbix API so every setting is stored in the
  5. ** Zabbix database itself -> survives image upgrades and is portable.
  6. */
  7. namespace Modules\WecomCenter\Lib;
  8. use API;
  9. use Exception;
  10. class Helper {
  11. // Zabbix trigger severity index => media severity bit (1 << index).
  12. public const SEVERITY_BITS = [1, 2, 4, 8, 16, 32];
  13. // Built-in alert schemes: name => [severity_mask, active(0=enabled,1=disabled)]
  14. public const SCHEMES = [
  15. 'standard' => [48, 0], // High + Disaster (recommended)
  16. 'warnplus' => [56, 0], // Average + High + Disaster
  17. 'all' => [63, 0], // everything
  18. 'quiet' => [32, 0], // Disaster only
  19. 'off' => [63, 1] // alerting disabled
  20. ];
  21. /* ------------------------------------------------------------------ */
  22. /* Media types / robot */
  23. /* ------------------------------------------------------------------ */
  24. /**
  25. * All webhook media types (type == 4).
  26. */
  27. public static function webhookMediatypes(): array {
  28. try {
  29. $res = API::Mediatype()->get([
  30. 'output' => ['mediatypeid', 'name', 'type', 'status'],
  31. 'selectParameters' => 'extend',
  32. 'filter' => ['type' => 4]
  33. ]);
  34. return is_array($res) ? $res : [];
  35. }
  36. catch (\Throwable $e) {
  37. return [];
  38. }
  39. }
  40. public static function getMediatype(string $mediatypeid): ?array {
  41. try {
  42. $res = API::Mediatype()->get([
  43. 'output' => ['mediatypeid', 'name', 'type', 'status'],
  44. 'selectParameters' => 'extend',
  45. 'mediatypeids' => [$mediatypeid]
  46. ]);
  47. return (is_array($res) && $res) ? $res[0] : null;
  48. }
  49. catch (\Throwable $e) {
  50. return null;
  51. }
  52. }
  53. // Fixed WeCom group-robot endpoint; the per-bot secret lives in {$WECOM_KEY}.
  54. public const WECOM_BASE = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=';
  55. public const KEY_MACRO = '{$WECOM_KEY}';
  56. /** Read a global macro value; null when missing. */
  57. public static function getGlobalMacro(string $name): ?string {
  58. try {
  59. $res = API::UserMacro()->get([
  60. 'globalmacro' => true,
  61. 'output' => ['globalmacroid', 'macro', 'value'],
  62. 'filter' => ['macro' => $name]
  63. ]);
  64. return ($res && isset($res[0])) ? (string) $res[0]['value'] : null;
  65. }
  66. catch (\Throwable $e) {
  67. return null;
  68. }
  69. }
  70. /** Create or update a global macro. */
  71. public static function setGlobalMacro(string $name, string $value): void {
  72. $res = API::UserMacro()->get([
  73. 'globalmacro' => true,
  74. 'output' => ['globalmacroid', 'macro'],
  75. 'filter' => ['macro' => $name]
  76. ]);
  77. if ($res && isset($res[0])) {
  78. API::UserMacro()->updateGlobal([
  79. ['globalmacroid' => $res[0]['globalmacroid'], 'value' => $value]
  80. ]);
  81. }
  82. else {
  83. API::UserMacro()->createGlobal([
  84. ['macro' => $name, 'value' => $value]
  85. ]);
  86. }
  87. }
  88. /**
  89. * Current robot key: {$WECOM_KEY} global macro, falling back to any legacy
  90. * value still stored in the media type parameters.
  91. */
  92. public static function webhookKey(?array $mediatype = null): string {
  93. $v = self::getGlobalMacro(self::KEY_MACRO);
  94. if ($v !== null && $v !== '') {
  95. return $v;
  96. }
  97. foreach (($mediatype['parameters'] ?? []) as $p) {
  98. $n = strtolower($p['name']);
  99. $val = (string) $p['value'];
  100. if ($n === 'key' && strpos($val, '{') === false) {
  101. return $val;
  102. }
  103. if (in_array($n, ['webhook_url', 'url', 'webhook'], true)) {
  104. if (strpos($val, 'key=') !== false) {
  105. return substr($val, strpos($val, 'key=') + 4);
  106. }
  107. if (strpos($val, 'http') !== 0) {
  108. return $val;
  109. }
  110. }
  111. }
  112. return '';
  113. }
  114. /** Persist the robot key into the {$WECOM_KEY} global macro (managed here, not in the media type). */
  115. public static function setWebhookKey(string $key): void {
  116. self::setGlobalMacro(self::KEY_MACRO, $key);
  117. }
  118. /** Full webhook URL for this bot = fixed base + {$WECOM_KEY}. */
  119. public static function webhookUrl(?array $mediatype = null): string {
  120. $key = self::webhookKey($mediatype);
  121. return $key === '' ? '' : self::WECOM_BASE.$key;
  122. }
  123. /* ------------------------------------------------------------------ */
  124. /* Robot user + media (severity / active) */
  125. /* ------------------------------------------------------------------ */
  126. /**
  127. * Find the dedicated robot user with its media.
  128. */
  129. public static function robotUser(string $username = 'wecomrobot'): ?array {
  130. $res = API::User()->get([
  131. 'output' => ['userid', 'username'],
  132. 'selectMedias' => 'extend',
  133. 'filter' => ['username' => $username]
  134. ]);
  135. return $res ? $res[0] : null;
  136. }
  137. /**
  138. * Current severity mask + active flag of a user's media for a media type.
  139. * Returns ['active'=>int, 'severity'=>int] or null when media absent.
  140. */
  141. public static function currentMedia(?array $user, string $mediatypeid): ?array {
  142. if (!$user || empty($user['medias'])) {
  143. return null;
  144. }
  145. foreach ($user['medias'] as $m) {
  146. if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
  147. return [
  148. 'active' => (int) $m['active'],
  149. 'severity' => (int) $m['severity'],
  150. 'period' => $m['period'] ?? '1-7,00:00-24:00',
  151. 'sendto' => $m['sendto'] ?? ''
  152. ];
  153. }
  154. }
  155. return null;
  156. }
  157. /**
  158. * Ensure a robot user exists; create one (no frontend access) if missing.
  159. */
  160. public static function ensureRobotUser(string $username): array {
  161. $user = self::robotUser($username);
  162. if ($user) {
  163. return $user;
  164. }
  165. // best-effort group / role for a non-login notification account
  166. $grp = API::UserGroup()->get([
  167. 'output' => ['usrgrpid', 'name'],
  168. 'filter' => ['name' => 'No access to the frontend'],
  169. 'limit' => 1
  170. ]);
  171. $usrgrpid = $grp ? $grp[0]['usrgrpid'] : null;
  172. if ($usrgrpid === null) {
  173. $all = API::UserGroup()->get(['output' => ['usrgrpid'], 'limit' => 1]);
  174. $usrgrpid = $all ? $all[0]['usrgrpid'] : null;
  175. }
  176. $role = API::Role()->get([
  177. 'output' => ['roleid', 'name'],
  178. 'filter' => ['name' => 'User role'],
  179. 'limit' => 1
  180. ]);
  181. $roleid = $role ? $role[0]['roleid'] : null;
  182. $create = [
  183. 'username' => $username,
  184. 'passwd' => bin2hex(random_bytes(16)).'Aa1!',
  185. 'usrgrps' => [['usrgrpid' => $usrgrpid]]
  186. ];
  187. if ($roleid !== null) {
  188. $create['roleid'] = $roleid;
  189. }
  190. $res = API::User()->create($create);
  191. return self::robotUser($username);
  192. }
  193. /**
  194. * Apply severity mask + active flag to a user's media for a media type.
  195. * Rebuilds the whole media list (API user.update replaces medias).
  196. */
  197. public static function applyMedia(string $userid, string $mediatypeid, int $severity, int $active,
  198. string $sendto = 'WeCom') {
  199. $res = API::User()->get([
  200. 'output' => ['userid'],
  201. 'selectMedias' => 'extend',
  202. 'userids' => [$userid]
  203. ]);
  204. $medias = [];
  205. if ($res) {
  206. foreach (($res[0]['medias'] ?? []) as $m) {
  207. $medias[] = [
  208. 'mediatypeid' => $m['mediatypeid'],
  209. 'sendto' => $m['sendto'],
  210. 'severity' => (string) $m['severity'],
  211. 'active' => (string) $m['active'],
  212. 'period' => $m['period'] ?: '1-7,00:00-24:00'
  213. ];
  214. }
  215. }
  216. $hit = false;
  217. foreach ($medias as &$m) {
  218. if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
  219. $m['severity'] = (string) $severity;
  220. $m['active'] = (string) $active;
  221. if ($sendto !== '') {
  222. $m['sendto'] = $sendto;
  223. }
  224. $hit = true;
  225. }
  226. }
  227. unset($m);
  228. if (!$hit) {
  229. $medias[] = [
  230. 'mediatypeid' => $mediatypeid,
  231. 'sendto' => $sendto !== '' ? $sendto : 'WeCom',
  232. 'severity' => (string) $severity,
  233. 'active' => (string) $active,
  234. 'period' => '1-7,00:00-24:00'
  235. ];
  236. }
  237. API::User()->update(['userid' => $userid, 'medias' => $medias]);
  238. }
  239. /* ------------------------------------------------------------------ */
  240. /* Send test message */
  241. /* ------------------------------------------------------------------ */
  242. public static function testWebhook(string $url, string $text): array {
  243. $payload = json_encode(['msgtype' => 'text', 'text' => ['content' => $text]], JSON_UNESCAPED_UNICODE);
  244. $ch = curl_init($url);
  245. curl_setopt_array($ch, [
  246. CURLOPT_RETURNTRANSFER => true,
  247. CURLOPT_POST => true,
  248. CURLOPT_POSTFIELDS => $payload,
  249. CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
  250. CURLOPT_TIMEOUT => 15,
  251. CURLOPT_SSL_VERIFYPEER => false,
  252. CURLOPT_SSL_VERIFYHOST => 0
  253. ]);
  254. $body = curl_exec($ch);
  255. $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  256. $err = curl_error($ch);
  257. curl_close($ch);
  258. return ['status' => $status, 'body' => $body !== false ? $body : $err];
  259. }
  260. /* ------------------------------------------------------------------ */
  261. /* Templates */
  262. /* ------------------------------------------------------------------ */
  263. /**
  264. * Bundled templates shipped inside modules/WecomCenter/templates/.
  265. */
  266. public static function bundledTemplates(string $module_dir): array {
  267. $dir = rtrim($module_dir, '/').'/templates';
  268. $out = [];
  269. if (!is_dir($dir)) {
  270. return $out;
  271. }
  272. foreach (scandir($dir) as $f) {
  273. if ($f === '.' || $f === '..') {
  274. continue;
  275. }
  276. $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
  277. if (in_array($ext, ['xml', 'yaml', 'yml', 'json'], true)) {
  278. $out[] = ['file' => $f, 'name' => pathinfo($f, PATHINFO_FILENAME)];
  279. }
  280. }
  281. return $out;
  282. }
  283. public static function importTemplate(string $module_dir, string $file): array {
  284. $dir = rtrim($module_dir, '/').'/templates';
  285. $file = basename($file);
  286. $path = $dir.'/'.$file;
  287. if (!is_file($path)) {
  288. throw new Exception('Template file not found: '.$file);
  289. }
  290. $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
  291. $format = ['xml' => 'xml', 'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml'][$ext] ?? 'yaml';
  292. $source = file_get_contents($path);
  293. $result = API::Configuration()->import([
  294. 'format' => $format,
  295. 'source' => $source,
  296. 'rules' => [
  297. 'template_groups' => ['createMissing' => true, 'updateExisting' => true],
  298. 'templates' => ['createMissing' => true, 'updateExisting' => true],
  299. 'items' => ['createMissing' => true, 'updateExisting' => true],
  300. 'triggers' => ['createMissing' => true, 'updateExisting' => true]
  301. ]
  302. ]);
  303. return is_array($result) ? $result : [];
  304. }
  305. /* ------------------------------------------------------------------ */
  306. /* Map coordinates */
  307. /* ------------------------------------------------------------------ */
  308. public static function hosts(): array {
  309. return API::Host()->get([
  310. 'output' => ['hostid', 'name'],
  311. 'sortfield' => 'name',
  312. 'limit' => 1000
  313. ]);
  314. }
  315. public static function setHostLocation(string $hostid, string $lat, string $lon): void {
  316. API::Host()->update([
  317. 'hostid' => $hostid,
  318. 'inventory_mode' => 0, // manual
  319. 'inventory' => [
  320. 'location_lat' => $lat,
  321. 'location_lon' => $lon
  322. ]
  323. ]);
  324. }
  325. public static function hostLocation(string $hostid): array {
  326. $res = API::Host()->get([
  327. 'output' => ['hostid', 'name'],
  328. 'selectInventory' => ['location_lat', 'location_lon'],
  329. 'hostids' => [$hostid]
  330. ]);
  331. return $res ? $res[0] : [];
  332. }
  333. /* ------------------------------------------------------------------ */
  334. /* Helpers */
  335. /* ------------------------------------------------------------------ */
  336. public static function severityMask(array $indices): int {
  337. $mask = 0;
  338. foreach ($indices as $i) {
  339. $i = (int) $i;
  340. if ($i >= 1 && $i <= 6) {
  341. $mask |= self::SEVERITY_BITS[$i - 1];
  342. }
  343. }
  344. return $mask;
  345. }
  346. /* ------------------------------------------------------------------ */
  347. /* Triggers (per-trigger alerting switch) */
  348. /* ------------------------------------------------------------------ */
  349. /**
  350. * Search triggers by description. An empty search returns nothing on
  351. * purpose - the instance has tens of thousands of triggers, listing all
  352. * is pointless. Results are capped.
  353. */
  354. public static function triggers(string $search, int $limit = 200): array {
  355. $search = trim($search);
  356. if ($search === '') {
  357. return [];
  358. }
  359. try {
  360. $res = API::Trigger()->get([
  361. 'output' => ['triggerid', 'description', 'status', 'priority'],
  362. 'selectHosts' => ['host'],
  363. 'search' => ['description' => $search],
  364. 'limit' => $limit
  365. ]);
  366. return is_array($res) ? $res : [];
  367. }
  368. catch (\Throwable $e) {
  369. return [];
  370. }
  371. }
  372. public static function triggerCount(): int {
  373. try {
  374. $n = API::Trigger()->get(['countOutput' => true]);
  375. return (int) $n;
  376. }
  377. catch (\Throwable $e) {
  378. return 0;
  379. }
  380. }
  381. /**
  382. * Set trigger status. $map = [triggerid => status(0 enabled,1 disabled)].
  383. * NB: this Zabbix version's trigger.update does NOT accept a "triggers"
  384. * array, so we update one trigger at a time.
  385. */
  386. public static function setTriggersStatus(array $map): int {
  387. $changed = 0;
  388. foreach ($map as $tid => $status) {
  389. $res = API::Trigger()->update([
  390. 'triggerid' => (string) $tid,
  391. 'status' => (string) ((int) $status)
  392. ]);
  393. if ($res === false) {
  394. throw new Exception('trigger.update failed for '.$tid);
  395. }
  396. $changed++;
  397. }
  398. return $changed;
  399. }
  400. /* ================================================================== */
  401. /* ALERT STRATEGY ENGINE */
  402. /* Classify triggers by name keyword, map to severity, apply to the */
  403. /* template triggers + prototypes (API) and discovered triggers (SQL)*/
  404. /* ================================================================== */
  405. public const TEMPLATE_NAME = 'Huawei VRP by SNMP walk';
  406. public const STRATEGY_MACRO = '{$WECOM_STRATEGY}';
  407. // Ordered classes - first match wins (highest priority first).
  408. public const CLASSES = [
  409. 'device_down' => [
  410. 'label' => 'cls.device_down',
  411. 'kw' => ['unavailable by icmp', 'icmp ping loss', 'no snmp data', 'no data', 'unreachable']
  412. ],
  413. 'hw_fault' => [
  414. 'label' => 'cls.hw_fault',
  415. 'kw' => ['fan is in critical', 'power supply', 'temperature is above critical',
  416. 'temperature is too low', 'is in critical state', 'psu']
  417. ],
  418. 'restarted' => [
  419. 'label' => 'cls.restarted',
  420. 'kw' => ['has been restarted', 'has been rebooted']
  421. ],
  422. 'core_link' => [
  423. 'label' => 'cls.core_link',
  424. 'kw' => ['link down'],
  425. 'core' => ['to:', 'dist', 'core', 'csw', 'uplink', 'aggregat', '汇聚', 'trunk']
  426. ],
  427. 'interface_down' => [
  428. 'label' => 'cls.interface_down',
  429. 'kw' => ['link down']
  430. ],
  431. 'perf' => [
  432. 'label' => 'cls.perf',
  433. 'kw' => ['high bandwidth usage', 'high error rate', 'high cpu', 'high memory', 'utilization',
  434. 'high icmp ping response time', 'changed to lower speed', 'temperature is above warning']
  435. ],
  436. 'other' => ['label' => 'cls.other', 'kw' => []]
  437. ];
  438. // Selectable alert strategies. min = lowest severity index that still notifies.
  439. public const STRATEGY_PRESETS = [
  440. 'steady' => [
  441. 'label' => 'preset.steady', 'desc' => 'preset.steady.desc', 'min' => 4,
  442. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  443. 'interface_down' => 3, 'perf' => 2]
  444. ],
  445. 'core' => [
  446. 'label' => 'preset.core', 'desc' => 'preset.core.desc', 'min' => 5,
  447. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  448. 'interface_down' => 2, 'perf' => 1]
  449. ],
  450. 'observe' => [
  451. 'label' => 'preset.observe', 'desc' => 'preset.observe.desc', 'min' => 2,
  452. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  453. 'interface_down' => 3, 'perf' => 2]
  454. ],
  455. 'night' => [
  456. 'label' => 'preset.night', 'desc' => 'preset.night.desc', 'min' => 5,
  457. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  458. 'interface_down' => 2, 'perf' => 1]
  459. ],
  460. 'balanced' => [
  461. 'label' => 'preset.balanced', 'desc' => 'preset.balanced.desc', 'min' => 3,
  462. 'targets' => ['device_down' => 5, 'hw_fault' => 5, 'restarted' => 5, 'core_link' => 5,
  463. 'interface_down' => 3, 'perf' => 2]
  464. ]
  465. ];
  466. /** Classify one trigger description into a class key. */
  467. public static function classifyRaw(string $desc): string {
  468. $d = strtolower($desc);
  469. foreach (self::CLASSES as $key => $def) {
  470. if (empty($def['kw'])) {
  471. continue;
  472. }
  473. $hit = false;
  474. foreach ($def['kw'] as $k) {
  475. if (strpos($d, $k) !== false) {
  476. $hit = true;
  477. break;
  478. }
  479. }
  480. if (!$hit) {
  481. continue;
  482. }
  483. if ($key === 'core_link') {
  484. if (self::hasCoreToken($d)) {
  485. return 'core_link';
  486. }
  487. continue; // non-core link-down -> interface_down
  488. }
  489. return $key;
  490. }
  491. return 'other';
  492. }
  493. private static function hasCoreToken(string $d): bool {
  494. foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) {
  495. if (strpos($d, $t) !== false) {
  496. return true;
  497. }
  498. }
  499. return false;
  500. }
  501. public static function templateHostid(): ?string {
  502. try {
  503. $res = API::Template()->get([
  504. 'output' => ['templateid', 'host'],
  505. 'filter' => ['host' => self::TEMPLATE_NAME],
  506. 'limit' => 1
  507. ]);
  508. if ($res) {
  509. return (string) $res[0]['templateid'];
  510. }
  511. $res = API::Host()->get([
  512. 'output' => ['hostid', 'host'],
  513. 'filter' => ['host' => self::TEMPLATE_NAME],
  514. 'limit' => 1
  515. ]);
  516. return $res ? (string) $res[0]['hostid'] : null;
  517. }
  518. catch (\Throwable $e) {
  519. return null;
  520. }
  521. }
  522. public static function severityMaskFromMin(int $min): int {
  523. $mask = 0;
  524. for ($i = max(1, $min); $i <= 6; $i++) {
  525. $mask |= (1 << ($i - 1));
  526. }
  527. return $mask;
  528. }
  529. public static function currentStrategy(): string {
  530. $v = self::getGlobalMacro(self::STRATEGY_MACRO);
  531. return $v === null ? '' : (string) $v;
  532. }
  533. public static function saveStrategyKey(string $key): void {
  534. self::setGlobalMacro(self::STRATEGY_MACRO, $key);
  535. }
  536. private static function sqlEsc(string $s): string {
  537. return str_replace(["\\", "'"], ["\\\\", "''"], $s);
  538. }
  539. private static function classCondSql(string $key): string {
  540. $def = self::CLASSES[$key] ?? null;
  541. if (!$def || empty($def['kw'])) {
  542. return '1=0';
  543. }
  544. $parts = [];
  545. foreach ($def['kw'] as $k) {
  546. $parts[] = "LOCATE('".self::sqlEsc($k)."', LOWER(t.description)) > 0";
  547. }
  548. $cond = '('.implode(' OR ', $parts).')';
  549. if ($key === 'core_link') {
  550. $cp = [];
  551. foreach (($def['core'] ?? []) as $t) {
  552. $cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0";
  553. }
  554. $cond .= ' AND ('.implode(' OR ', $cp).')';
  555. }
  556. elseif ($key === 'interface_down') {
  557. $cp = [];
  558. foreach ((self::CLASSES['core_link']['core'] ?? []) as $t) {
  559. $cp[] = "LOCATE('".self::sqlEsc($t)."', LOWER(t.description)) > 0";
  560. }
  561. $cond .= ' AND NOT ('.implode(' OR ', $cp).')';
  562. }
  563. return $cond;
  564. }
  565. private static function scopeSql(string $tid): string {
  566. return 'EXISTS (SELECT 1 FROM functions f JOIN items i ON i.itemid=f.itemid '
  567. .'JOIN hosts_templates ht ON ht.hostid=i.hostid '
  568. .'WHERE f.triggerid=t.triggerid AND ht.templateid='.(int) $tid.')';
  569. }
  570. /** Dry-run: per-class {count, changes, target} over hosted (discovered) triggers. */
  571. public static function previewStrategy(string $tid, array $targets): array {
  572. $out = ['total' => 0, 'changes' => 0, 'classes' => []];
  573. $scope = self::scopeSql($tid);
  574. foreach ($targets as $key => $target) {
  575. if ($key === 'other') {
  576. continue;
  577. }
  578. $cond = self::classCondSql($key);
  579. $row = DBfetch(DBselect(
  580. 'SELECT COUNT(*) AS c, SUM(CASE WHEN t.priority<>'.(int) $target.' THEN 1 ELSE 0 END) AS ch '
  581. .'FROM triggers t WHERE t.flags=4 AND '.$scope.' AND '.$cond
  582. ));
  583. $c = $row ? (int) $row['c'] : 0;
  584. $ch = $row ? (int) $row['ch'] : 0;
  585. $out['classes'][$key] = ['count' => $c, 'changes' => $ch, 'target' => (int) $target];
  586. $out['total'] += $c;
  587. $out['changes'] += $ch;
  588. }
  589. return $out;
  590. }
  591. /**
  592. * Apply a strategy:
  593. * - template triggers -> API trigger.update (instant propagation)
  594. * - trigger prototypes -> API triggerprototype.update (future re-discovery)
  595. * - discovered triggers -> SQL priority update (takes effect immediately)
  596. */
  597. public static function applyStrategy(string $tid, array $targets): array {
  598. $res = ['trigger' => 0, 'prototype' => 0, 'discovered' => 0];
  599. $trs = API::Trigger()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]);
  600. foreach ($trs as $t) {
  601. $c = self::classifyRaw((string) $t['description']);
  602. if (!isset($targets[$c]) || $c === 'other') {
  603. continue;
  604. }
  605. $tg = (int) $targets[$c];
  606. if ((int) $t['priority'] !== $tg) {
  607. API::Trigger()->update(['triggerid' => $t['triggerid'], 'priority' => $tg]);
  608. $res['trigger']++;
  609. }
  610. }
  611. $prs = API::TriggerPrototype()->get(['hostids' => $tid, 'output' => ['triggerid', 'description', 'priority']]);
  612. foreach ($prs as $p) {
  613. $c = self::classifyRaw((string) $p['description']);
  614. if (!isset($targets[$c]) || $c === 'other') {
  615. continue;
  616. }
  617. $tg = (int) $targets[$c];
  618. if ((int) $p['priority'] !== $tg) {
  619. API::TriggerPrototype()->update(['triggerid' => $p['triggerid'], 'priority' => $tg]);
  620. $res['prototype']++;
  621. }
  622. }
  623. // Discovered triggers: low priority classes first so higher priority wins on overlap.
  624. $order = ['perf', 'interface_down', 'core_link', 'restarted', 'hw_fault', 'device_down'];
  625. $scope = self::scopeSql($tid);
  626. foreach ($order as $key) {
  627. if (!isset($targets[$key])) {
  628. continue;
  629. }
  630. $tg = (int) $targets[$key];
  631. $cond = self::classCondSql($key);
  632. $cnt = DBfetch(DBselect(
  633. 'SELECT COUNT(*) AS c FROM triggers t WHERE t.flags=4 AND '.$scope
  634. .' AND '.$cond.' AND t.priority<>'.$tg
  635. ));
  636. $n = $cnt ? (int) $cnt['c'] : 0;
  637. if ($n > 0) {
  638. $ok = DBexecute('UPDATE triggers AS t SET t.priority='.$tg
  639. .' WHERE t.flags=4 AND '.$scope.' AND '.$cond.' AND t.priority<>'.$tg);
  640. if ($ok === false) {
  641. throw new Exception('DB update failed for class '.$key);
  642. }
  643. $res['discovered'] += $n;
  644. }
  645. }
  646. return $res;
  647. }
  648. /* ================================================================== */
  649. /* SERVER TUNING GENERATOR (Zabbix server process / cache settings) */
  650. /* Produces a copy-paste env block + restart command. Nothing is */
  651. /* written here - the operator applies it on the host (zero risk). */
  652. /* ================================================================== */
  653. public const TUNING_PROFILES = [
  654. 'small' => [
  655. 'StartSNMPPollers' => 10, 'StartPollers' => 10, 'StartLLDWorkers' => 5,
  656. 'StartPreprocessors' => 3, 'CacheSize' => '256M', 'ValueCacheSize' => '64M',
  657. 'HistoryCacheSize' => '32M', 'HistoryIndexCacheSize' => '8M', 'TrendCacheSize' => '32M'
  658. ],
  659. 'medium' => [
  660. 'StartSNMPPollers' => 30, 'StartPollers' => 20, 'StartLLDWorkers' => 10,
  661. 'StartPreprocessors' => 5, 'CacheSize' => '512M', 'ValueCacheSize' => '128M',
  662. 'HistoryCacheSize' => '64M', 'HistoryIndexCacheSize' => '16M', 'TrendCacheSize' => '64M'
  663. ],
  664. 'large' => [
  665. 'StartSNMPPollers' => 60, 'StartPollers' => 40, 'StartLLDWorkers' => 20,
  666. 'StartPreprocessors' => 10, 'CacheSize' => '1G', 'ValueCacheSize' => '256M',
  667. 'HistoryCacheSize' => '128M', 'HistoryIndexCacheSize' => '32M', 'TrendCacheSize' => '128M'
  668. ]
  669. ];
  670. /**
  671. * Build a compose .env / environment snippet for the chosen profile, plus
  672. * the command to recreate the server container.
  673. * @return array{env:string, cmd:string}
  674. */
  675. public static function tuningSnippet(string $profile): array {
  676. $vals = self::TUNING_PROFILES[$profile] ?? self::TUNING_PROFILES['medium'];
  677. $lines = [];
  678. foreach ($vals as $k => $v) {
  679. $lines[] = 'ZBX_'.$k.'='.$v;
  680. }
  681. $env = implode("\n", $lines);
  682. $cmd = "# paste the ZBX_* lines into /opt/zabbix/.env (or docker-compose environment)\n"
  683. ."cd /opt/zabbix && docker compose up -d zabbix-server";
  684. return ['env' => $env, 'cmd' => $cmd];
  685. }
  686. /* ================================================================== */
  687. /* TEMPLATE SWITCH / MIGRATION (old template -> new template) */
  688. /* ================================================================== */
  689. /** Huawei-related templates available on this server (source/target candidates). */
  690. public static function huaweiTemplates(): array {
  691. try {
  692. $res = API::Template()->get([
  693. 'output' => ['templateid', 'host', 'name'],
  694. 'search' => ['host' => 'Huawei'],
  695. 'sortfield' => 'host'
  696. ]);
  697. return $res ?: [];
  698. }
  699. catch (\Throwable $e) {
  700. return [];
  701. }
  702. }
  703. /** Hosts currently linked to the given template. */
  704. public static function hostsOnTemplate(string $tid): array {
  705. $res = API::Host()->get([
  706. 'output' => ['hostid', 'host', 'name'],
  707. 'templateids' => [$tid],
  708. 'selectParentTemplates' => ['templateid'],
  709. 'sortfield' => 'name'
  710. ]);
  711. return $res ?: [];
  712. }
  713. public static function templateNames(array $ids): array {
  714. $out = [];
  715. if (!$ids) {
  716. return $out;
  717. }
  718. $res = API::Template()->get(['output' => ['templateid', 'host'], 'templateids' => $ids]);
  719. foreach ($res as $t) {
  720. $out[$t['templateid']] = $t['host'];
  721. }
  722. return $out;
  723. }
  724. /** Dry-run: which hosts would move from $src to $dst. */
  725. public static function switchPreview(string $src, string $dst): array {
  726. $hosts = self::hostsOnTemplate($src);
  727. $names = [];
  728. foreach ($hosts as $h) {
  729. $names[] = ($h['name'] !== '') ? $h['name'] : $h['host'];
  730. }
  731. $tn = self::templateNames([$src, $dst]);
  732. return [
  733. 'src' => $src,
  734. 'dst' => $dst,
  735. 'src_name' => $tn[$src] ?? $src,
  736. 'dst_name' => $tn[$dst] ?? $dst,
  737. 'count' => count($hosts),
  738. 'hosts' => array_slice($names, 0, 300)
  739. ];
  740. }
  741. /** Replace the source template link with the target for every host on source. */
  742. public static function switchApply(string $src, string $dst): int {
  743. if ($src === $dst) {
  744. throw new Exception('Source and target template are the same.');
  745. }
  746. $hosts = self::hostsOnTemplate($src);
  747. $n = 0;
  748. foreach ($hosts as $h) {
  749. $new = [];
  750. $has_dst = false;
  751. foreach (($h['parentTemplates'] ?? []) as $pt) {
  752. if ((string) $pt['templateid'] === $src) {
  753. continue;
  754. }
  755. if ((string) $pt['templateid'] === $dst) {
  756. $has_dst = true;
  757. }
  758. $new[] = ['templateid' => $pt['templateid']];
  759. }
  760. if (!$has_dst) {
  761. $new[] = ['templateid' => $dst];
  762. }
  763. API::Host()->update(['hostid' => $h['hostid'], 'templates' => $new]);
  764. $n++;
  765. }
  766. return $n;
  767. }
  768. }