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 = "**🔴 {TRIGGER.SEVERITY} · {HOST.NAME}**\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 = "**🟢 已恢复 · {HOST.NAME}**\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;
}
}