| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066 |
- <?php declare(strict_types = 0);
- /*
- ** WeCom Center - business logic helpers.
- ** Works through the official Zabbix API so every setting is stored in the
- ** Zabbix database itself -> survives image upgrades and is portable.
- */
- namespace Modules\WecomCenter\Lib;
- use API;
- use Exception;
- class Helper {
- // Zabbix trigger severity index => media severity bit (1 << index).
- public const SEVERITY_BITS = [1, 2, 4, 8, 16, 32];
- // Built-in alert schemes: name => [severity_mask, active(0=enabled,1=disabled)]
- public const SCHEMES = [
- 'standard' => [48, 0], // High + Disaster (recommended)
- 'warnplus' => [56, 0], // Average + High + Disaster
- 'all' => [63, 0], // everything
- 'quiet' => [32, 0], // Disaster only
- 'off' => [63, 1] // alerting disabled
- ];
- /* ------------------------------------------------------------------ */
- /* Media types / robot */
- /* ------------------------------------------------------------------ */
- /**
- * All webhook media types (type == 4).
- */
- public static function webhookMediatypes(): array {
- try {
- $res = API::Mediatype()->get([
- 'output' => ['mediatypeid', 'name', 'type', 'status'],
- 'selectParameters' => 'extend',
- 'filter' => ['type' => 4]
- ]);
- return is_array($res) ? $res : [];
- }
- catch (\Throwable $e) {
- return [];
- }
- }
- public static function getMediatype(string $mediatypeid): ?array {
- try {
- $res = API::Mediatype()->get([
- 'output' => ['mediatypeid', 'name', 'type', 'status'],
- 'selectParameters' => 'extend',
- 'mediatypeids' => [$mediatypeid]
- ]);
- return (is_array($res) && $res) ? $res[0] : null;
- }
- catch (\Throwable $e) {
- return null;
- }
- }
- // Fixed WeCom group-robot endpoint; the per-bot secret lives in {$WECOM_KEY}.
- public const WECOM_BASE = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=';
- public const KEY_MACRO = '{$WECOM_KEY}';
- /** Read a global macro value; null when missing. */
- public static function getGlobalMacro(string $name): ?string {
- try {
- $res = API::UserMacro()->get([
- 'globalmacro' => true,
- 'output' => ['globalmacroid', 'macro', 'value'],
- 'filter' => ['macro' => $name]
- ]);
- return ($res && isset($res[0])) ? (string) $res[0]['value'] : null;
- }
- catch (\Throwable $e) {
- return null;
- }
- }
- /** Create or update a global macro. */
- public static function setGlobalMacro(string $name, string $value): void {
- $res = API::UserMacro()->get([
- 'globalmacro' => true,
- 'output' => ['globalmacroid', 'macro'],
- 'filter' => ['macro' => $name]
- ]);
- if ($res && isset($res[0])) {
- API::UserMacro()->updateGlobal([
- ['globalmacroid' => $res[0]['globalmacroid'], 'value' => $value]
- ]);
- }
- else {
- API::UserMacro()->createGlobal([
- ['macro' => $name, 'value' => $value]
- ]);
- }
- }
- /**
- * Current robot key: {$WECOM_KEY} global macro, falling back to any legacy
- * value still stored in the media type parameters.
- */
- public static function webhookKey(?array $mediatype = null): string {
- $v = self::getGlobalMacro(self::KEY_MACRO);
- if ($v !== null && $v !== '') {
- return $v;
- }
- foreach (($mediatype['parameters'] ?? []) as $p) {
- $n = strtolower($p['name']);
- $val = (string) $p['value'];
- if ($n === 'key' && strpos($val, '{') === false) {
- return $val;
- }
- if (in_array($n, ['webhook_url', 'url', 'webhook'], true)) {
- if (strpos($val, 'key=') !== false) {
- return substr($val, strpos($val, 'key=') + 4);
- }
- if (strpos($val, 'http') !== 0) {
- return $val;
- }
- }
- }
- return '';
- }
- /** Persist the robot key into the {$WECOM_KEY} global macro (managed here, not in the media type). */
- public static function setWebhookKey(string $key): void {
- self::setGlobalMacro(self::KEY_MACRO, $key);
- }
- /** Full webhook URL for this bot = fixed base + {$WECOM_KEY}. */
- public static function webhookUrl(?array $mediatype = null): string {
- $key = self::webhookKey($mediatype);
- return $key === '' ? '' : self::WECOM_BASE.$key;
- }
- /* ------------------------------------------------------------------ */
- /* Robot user + media (severity / active) */
- /* ------------------------------------------------------------------ */
- /**
- * Find the dedicated robot user with its media.
- */
- public static function robotUser(string $username = 'wecomrobot'): ?array {
- $res = API::User()->get([
- 'output' => ['userid', 'username'],
- 'selectMedias' => 'extend',
- 'filter' => ['username' => $username]
- ]);
- return $res ? $res[0] : null;
- }
- /**
- * Current severity mask + active flag of a user's media for a media type.
- * Returns ['active'=>int, 'severity'=>int] or null when media absent.
- */
- public static function currentMedia(?array $user, string $mediatypeid): ?array {
- if (!$user || empty($user['medias'])) {
- return null;
- }
- foreach ($user['medias'] as $m) {
- if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
- return [
- 'active' => (int) $m['active'],
- 'severity' => (int) $m['severity'],
- 'period' => $m['period'] ?? '1-7,00:00-24:00',
- 'sendto' => $m['sendto'] ?? ''
- ];
- }
- }
- return null;
- }
- /**
- * Ensure a robot user exists; create one (no frontend access) if missing.
- */
- public static function ensureRobotUser(string $username): array {
- $user = self::robotUser($username);
- if ($user) {
- return $user;
- }
- // best-effort group / role for a non-login notification account
- $grp = API::UserGroup()->get([
- 'output' => ['usrgrpid', 'name'],
- 'filter' => ['name' => 'No access to the frontend'],
- 'limit' => 1
- ]);
- $usrgrpid = $grp ? $grp[0]['usrgrpid'] : null;
- if ($usrgrpid === null) {
- $all = API::UserGroup()->get(['output' => ['usrgrpid'], 'limit' => 1]);
- $usrgrpid = $all ? $all[0]['usrgrpid'] : null;
- }
- $role = API::Role()->get([
- 'output' => ['roleid', 'name'],
- 'filter' => ['name' => 'User role'],
- 'limit' => 1
- ]);
- $roleid = $role ? $role[0]['roleid'] : null;
- $create = [
- 'username' => $username,
- 'passwd' => bin2hex(random_bytes(16)).'Aa1!',
- 'usrgrps' => [['usrgrpid' => $usrgrpid]]
- ];
- if ($roleid !== null) {
- $create['roleid'] = $roleid;
- }
- $res = API::User()->create($create);
- return self::robotUser($username);
- }
- /**
- * Apply severity mask + active flag to a user's media for a media type.
- * Rebuilds the whole media list (API user.update replaces medias).
- */
- public static function applyMedia(string $userid, string $mediatypeid, int $severity, int $active,
- string $sendto = 'WeCom') {
- $res = API::User()->get([
- 'output' => ['userid'],
- 'selectMedias' => 'extend',
- 'userids' => [$userid]
- ]);
- $medias = [];
- if ($res) {
- foreach (($res[0]['medias'] ?? []) as $m) {
- $medias[] = [
- 'mediatypeid' => $m['mediatypeid'],
- 'sendto' => $m['sendto'],
- 'severity' => (string) $m['severity'],
- 'active' => (string) $m['active'],
- 'period' => $m['period'] ?: '1-7,00:00-24:00'
- ];
- }
- }
- $hit = false;
- foreach ($medias as &$m) {
- if ((string) $m['mediatypeid'] === (string) $mediatypeid) {
- $m['severity'] = (string) $severity;
- $m['active'] = (string) $active;
- if ($sendto !== '') {
- $m['sendto'] = $sendto;
- }
- $hit = true;
- }
- }
- unset($m);
- if (!$hit) {
- $medias[] = [
- 'mediatypeid' => $mediatypeid,
- 'sendto' => $sendto !== '' ? $sendto : 'WeCom',
- 'severity' => (string) $severity,
- 'active' => (string) $active,
- 'period' => '1-7,00:00-24:00'
- ];
- }
- API::User()->update(['userid' => $userid, 'medias' => $medias]);
- }
- /* ------------------------------------------------------------------ */
- /* Send test message */
- /* ------------------------------------------------------------------ */
- public static function testWebhook(string $url, string $text): array {
- $payload = json_encode(['msgtype' => 'text', 'text' => ['content' => $text]], JSON_UNESCAPED_UNICODE);
- $ch = curl_init($url);
- curl_setopt_array($ch, [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_POST => true,
- CURLOPT_POSTFIELDS => $payload,
- CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
- CURLOPT_TIMEOUT => 15,
- CURLOPT_SSL_VERIFYPEER => false,
- CURLOPT_SSL_VERIFYHOST => 0
- ]);
- $body = curl_exec($ch);
- $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- $err = curl_error($ch);
- curl_close($ch);
- return ['status' => $status, 'body' => $body !== false ? $body : $err];
- }
- /* ------------------------------------------------------------------ */
- /* Templates */
- /* ------------------------------------------------------------------ */
- /**
- * Bundled templates shipped inside modules/WecomCenter/templates/.
- */
- public static function bundledTemplates(string $module_dir): array {
- $dir = rtrim($module_dir, '/').'/templates';
- $out = [];
- if (!is_dir($dir)) {
- return $out;
- }
- foreach (scandir($dir) as $f) {
- if ($f === '.' || $f === '..') {
- continue;
- }
- $ext = strtolower(pathinfo($f, PATHINFO_EXTENSION));
- if (in_array($ext, ['xml', 'yaml', 'yml', 'json'], true)) {
- $out[] = ['file' => $f, 'name' => pathinfo($f, PATHINFO_FILENAME)];
- }
- }
- return $out;
- }
- public static function importTemplate(string $module_dir, string $file): array {
- $dir = rtrim($module_dir, '/').'/templates';
- $file = basename($file);
- $path = $dir.'/'.$file;
- if (!is_file($path)) {
- throw new Exception('Template file not found: '.$file);
- }
- $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
- $format = ['xml' => 'xml', 'json' => 'json', 'yaml' => 'yaml', 'yml' => 'yaml'][$ext] ?? 'yaml';
- $source = file_get_contents($path);
- $result = API::Configuration()->import([
- 'format' => $format,
- 'source' => $source,
- 'rules' => [
- 'template_groups' => ['createMissing' => true, 'updateExisting' => true],
- 'templates' => ['createMissing' => true, 'updateExisting' => true],
- 'items' => ['createMissing' => true, 'updateExisting' => true],
- 'triggers' => ['createMissing' => true, 'updateExisting' => true]
- ]
- ]);
- return is_array($result) ? $result : [];
- }
- /* ------------------------------------------------------------------ */
- /* Map coordinates */
- /* ------------------------------------------------------------------ */
- public static function hosts(): array {
- return API::Host()->get([
- 'output' => ['hostid', 'name'],
- 'sortfield' => 'name',
- 'limit' => 1000
- ]);
- }
- public static function setHostLocation(string $hostid, string $lat, string $lon): void {
- API::Host()->update([
- 'hostid' => $hostid,
- 'inventory_mode' => 0, // manual
- 'inventory' => [
- 'location_lat' => $lat,
- 'location_lon' => $lon
- ]
- ]);
- }
- public static function hostLocation(string $hostid): array {
- $res = API::Host()->get([
- 'output' => ['hostid', 'name'],
- 'selectInventory' => ['location_lat', 'location_lon'],
- 'hostids' => [$hostid]
- ]);
- return $res ? $res[0] : [];
- }
- /* ------------------------------------------------------------------ */
- /* Helpers */
- /* ------------------------------------------------------------------ */
- public static function severityMask(array $indices): int {
- $mask = 0;
- foreach ($indices as $i) {
- $i = (int) $i;
- if ($i >= 1 && $i <= 6) {
- $mask |= self::SEVERITY_BITS[$i - 1];
- }
- }
- return $mask;
- }
- /* ------------------------------------------------------------------ */
- /* Triggers (per-trigger alerting switch) */
- /* ------------------------------------------------------------------ */
- /**
- * Search triggers by description. An empty search returns nothing on
- * purpose - the instance has tens of thousands of triggers, listing all
- * is pointless. Results are capped.
- */
- public static function triggers(string $search, int $limit = 200): array {
- $search = trim($search);
- if ($search === '') {
- return [];
- }
- try {
- $res = API::Trigger()->get([
- 'output' => ['triggerid', 'description', 'status', 'priority'],
- 'selectHosts' => ['host'],
- 'search' => ['description' => $search],
- 'limit' => $limit
- ]);
- return is_array($res) ? $res : [];
- }
- catch (\Throwable $e) {
- return [];
- }
- }
- public static function triggerCount(): int {
- try {
- $n = API::Trigger()->get(['countOutput' => true]);
- return (int) $n;
- }
- catch (\Throwable $e) {
- return 0;
- }
- }
- /**
- * Set trigger status. $map = [triggerid => status(0 enabled,1 disabled)].
- * NB: this Zabbix version's trigger.update does NOT accept a "triggers"
- * array, so we update one trigger at a time.
- */
- public static function setTriggersStatus(array $map): int {
- $changed = 0;
- foreach ($map as $tid => $status) {
- $res = API::Trigger()->update([
- 'triggerid' => (string) $tid,
- 'status' => (string) ((int) $status)
- ]);
- if ($res === false) {
- throw new Exception('trigger.update failed for '.$tid);
- }
- $changed++;
- }
- 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 */
- /* 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];
- }
- /* ================================================================== */
- /* TEMPLATE SWITCH / MIGRATION (old template -> new template) */
- /* ================================================================== */
- /** Huawei-related templates available on this server (source/target candidates). */
- public static function huaweiTemplates(): array {
- try {
- $res = API::Template()->get([
- 'output' => ['templateid', 'host', 'name'],
- 'search' => ['host' => 'Huawei'],
- 'sortfield' => 'host'
- ]);
- return $res ?: [];
- }
- catch (\Throwable $e) {
- return [];
- }
- }
- /** Hosts currently linked to the given template. */
- public static function hostsOnTemplate(string $tid): array {
- $res = API::Host()->get([
- 'output' => ['hostid', 'host', 'name'],
- 'templateids' => [$tid],
- 'selectParentTemplates' => ['templateid'],
- 'sortfield' => 'name'
- ]);
- return $res ?: [];
- }
- public static function templateNames(array $ids): array {
- $out = [];
- if (!$ids) {
- return $out;
- }
- $res = API::Template()->get(['output' => ['templateid', 'host'], 'templateids' => $ids]);
- foreach ($res as $t) {
- $out[$t['templateid']] = $t['host'];
- }
- return $out;
- }
- /** Dry-run: which hosts would move from $src to $dst. */
- public static function switchPreview(string $src, string $dst): array {
- $hosts = self::hostsOnTemplate($src);
- $names = [];
- foreach ($hosts as $h) {
- $names[] = ($h['name'] !== '') ? $h['name'] : $h['host'];
- }
- $tn = self::templateNames([$src, $dst]);
- return [
- 'src' => $src,
- 'dst' => $dst,
- 'src_name' => $tn[$src] ?? $src,
- 'dst_name' => $tn[$dst] ?? $dst,
- 'count' => count($hosts),
- 'hosts' => array_slice($names, 0, 300)
- ];
- }
- /** Replace the source template link with the target for every host on source. */
- public static function switchApply(string $src, string $dst): int {
- if ($src === $dst) {
- throw new Exception('Source and target template are the same.');
- }
- $hosts = self::hostsOnTemplate($src);
- $n = 0;
- foreach ($hosts as $h) {
- $new = [];
- $has_dst = false;
- foreach (($h['parentTemplates'] ?? []) as $pt) {
- if ((string) $pt['templateid'] === $src) {
- continue;
- }
- if ((string) $pt['templateid'] === $dst) {
- $has_dst = true;
- }
- $new[] = ['templateid' => $pt['templateid']];
- }
- if (!$has_dst) {
- $new[] = ['templateid' => $dst];
- }
- API::Host()->update(['hostid' => $h['hostid'], 'templates' => $new]);
- $n++;
- }
- return $n;
- }
- /* ================================================================== */
- /* ONE-CLICK ENVIRONMENT INITIALISATION (fresh Zabbix bootstrap) */
- /* Creates whatever is missing so the module works out of the box: */
- /* {$WECOM_KEY} macro, webhook media type, robot user + media, */
- /* and the trigger action. Existing objects are reused, never duped. */
- /* ================================================================== */
- public const MEDIATYPE_NAME = 'WeCom Group Robot';
- public const ROBOT_USERNAME = 'wecomrobot';
- public const ACTION_NAME = 'Send alarm by Wecom Robot';
- public const ZBX_URL_MACRO = '{$ZBX_URL}';
- public const WEBHOOK_SCRIPT = <<<'JS'
- var params = JSON.parse(value);
- var content = params.message || 'Zabbix';
- var url = 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=' + params.key;
- function send(payload){
- var r = new HttpRequest();
- r.addHeader('Content-Type: application/json');
- var resp = r.post(url, JSON.stringify(payload));
- return {status: r.getStatus(), body: resp};
- }
- var res = send({msgtype: 'markdown', markdown: {content: content}});
- if (res.status !== 200) {
- var res2 = send({msgtype: 'text', text: {content: content}});
- if (res2.status !== 200) {
- Zabbix.Log(4, 'WeCom webhook error HTTP ' + res.status + ' body: ' + res.body);
- throw 'HTTP ' + res.status + ' => ' + res.body;
- }
- }
- return 'Message sent successfully';
- JS;
- /** Find an existing WeCom webhook media type (ours by name, else one bound to {$WECOM_KEY}). */
- public static function findWebhookMediatype(): ?array {
- $mts = self::webhookMediatypes();
- foreach ($mts as $m) {
- if (isset($m['name']) && $m['name'] === self::MEDIATYPE_NAME) {
- return $m;
- }
- }
- foreach ($mts as $m) {
- foreach (($m['parameters'] ?? []) as $p) {
- if (strtolower((string) $p['name']) === 'key' && strpos((string) $p['value'], 'WECOM_KEY') !== false) {
- return $m;
- }
- }
- }
- foreach ($mts as $m) {
- if (stripos((string) $m['name'], 'wecom robot') !== false) {
- return $m;
- }
- }
- return null;
- }
- /** Ensure the WeCom webhook media type exists. @return array{0:string,1:bool} [mediatypeid, created] */
- public static function ensureMediatype(): array {
- $found = self::findWebhookMediatype();
- if ($found) {
- return [(string) $found['mediatypeid'], false];
- }
- $res = API::Mediatype()->create([
- 'name' => self::MEDIATYPE_NAME,
- 'type' => 4, // webhook
- 'status' => 0, // enabled
- 'message_format' => 1, // markdown
- 'description' => 'Created by WeCom Center: posts alerts to a WeCom group robot.',
- 'timeout' => '30s',
- 'script' => self::WEBHOOK_SCRIPT,
- 'parameters' => [
- ['name' => 'message', 'value' => '{ALERT.MESSAGE}'],
- ['name' => 'key', 'value' => self::KEY_MACRO]
- ]
- ]);
- return [(string) $res['mediatypeids'][0], true];
- }
- /** Ensure the trigger action exists and points to the robot user + media type. */
- public static function ensureAction(string $mediatypeid, string $userid): bool {
- $ex = API::Action()->get([
- 'output' => ['actionid'],
- 'filter' => ['name' => self::ACTION_NAME]
- ]);
- if ($ex) {
- return false;
- }
- $problem = "<font color=\"warning\">**🔴 {TRIGGER.SEVERITY} · {HOST.NAME}**</font>\n"
- ."> 问题:{TRIGGER.NAME}\n"
- ."> 时间:{EVENT.DATE} {EVENT.TIME} | 持续:{EVENT.AGE}\n"
- ."> 当前值:{ITEM.LASTVALUE}\n"
- ."[查看详情]({$ZBX_URL}/tr_events.php?triggerid={TRIGGER.ID}&eventid={EVENT.ID})";
- $recovery = "<font color=\"info\">**🟢 已恢复 · {HOST.NAME}**</font>\n"
- ."> 问题:{TRIGGER.NAME}\n"
- ."> 恢复时间:{EVENT.RECOVERY.DATE} {EVENT.RECOVERY.TIME}\n"
- ."> 故障持续:{EVENT.DURATION}\n"
- ."[查看详情]({$ZBX_URL}/tr_events.php?triggerid={TRIGGER.ID}&eventid={EVENT.ID})";
- API::Action()->create([
- 'name' => self::ACTION_NAME,
- 'eventsource' => 0, // trigger actions
- 'status' => 0,
- 'esc_period' => '10m',
- 'filter' => ['evaltype' => 0, 'conditions' => []],
- 'operations' => [[
- 'operationtype' => 0,
- 'esc_step_from' => 1,
- 'esc_step_to' => 1,
- 'opmessage' => [
- 'default_msg' => 0,
- 'subject' => '[Zabbix Alarm] {TRIGGER.NAME}',
- 'message' => $problem,
- 'mediatypeid' => $mediatypeid
- ],
- 'opmessage_usr' => [['userid' => $userid]]
- ]],
- 'recovery_operations' => [[
- 'operationtype' => 0,
- 'opmessage' => [
- 'default_msg' => 0,
- 'subject' => '[Zabbix Recovery] {TRIGGER.NAME}',
- 'message' => $recovery,
- 'mediatypeid' => $mediatypeid
- ],
- 'opmessage_usr' => [['userid' => $userid]]
- ]]
- ]);
- return true;
- }
- /**
- * Idempotent bootstrap. Safe to run repeatedly.
- * @return array{key_set:bool,zbx_url:bool,mediatypeid:string,created_mediatype:bool,created_user:bool,created_action:bool}
- */
- public static function initializeEnvironment(string $key, string $zbxUrl = ''): array {
- $out = [
- 'key_set' => false, 'zbx_url' => false, 'mediatypeid' => '',
- 'created_mediatype' => false, 'created_user' => false, 'created_action' => false
- ];
- if ($key !== '') {
- self::setWebhookKey($key);
- $out['key_set'] = true;
- }
- if ($zbxUrl !== '' && self::getGlobalMacro(self::ZBX_URL_MACRO) === null) {
- self::setGlobalMacro(self::ZBX_URL_MACRO, $zbxUrl);
- $out['zbx_url'] = true;
- }
- [$mtid, $createdMt] = self::ensureMediatype();
- $out['mediatypeid'] = $mtid;
- $out['created_mediatype'] = $createdMt;
- $existed = self::robotUser(self::ROBOT_USERNAME) !== null;
- $user = self::ensureRobotUser(self::ROBOT_USERNAME);
- $out['created_user'] = !$existed;
- $uid = $user['userid'] ?? '';
- if ($uid !== '') {
- self::applyMedia((string) $uid, $mtid, 48, 0); // High + Disaster, enabled
- $out['created_action'] = self::ensureAction($mtid, (string) $uid);
- }
- return $out;
- }
- }
|