From 440bb063bfb45b89160f961f23287934e51cbfde Mon Sep 17 00:00:00 2001 From: jxl1216 <49269749+jxl1216@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:15:27 +0800 Subject: [PATCH 1/3] feat: add zabbix_clonehosts module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new module that batch-clones and imports hosts based on an existing monitored host's configuration. Features: source host cloning with full config inheritance; dual-mode data entry (CSV upload + online table); smart field inheritance; auto-creation of missing host groups; preview with conflict detection; selective import via per-row checkboxes; back-to-edit with data preserved; sequential AJAX import with progress bar; CSV result report; bilingual UI (zh_CN/en_GB); compatible with Zabbix 6.0/6.4/7.x/8.x via CompatHelper. Authors: 先龍 / Mars XiaoLiu --- README.md | 30 + README_en.md | 14 + zabbix_clonehosts/CompatHelper.php | 159 +++ zabbix_clonehosts/LangHelper.php | 686 +++++++++++ zabbix_clonehosts/Module.php | 53 + zabbix_clonehosts/README.md | 133 +++ zabbix_clonehosts/README_en.md | 123 ++ zabbix_clonehosts/actions/Clonehosts.php | 127 ++ .../actions/ClonehostsImport.php | 357 ++++++ .../actions/ClonehostsPreview.php | 247 ++++ .../actions/ClonehostsSource.php | 93 ++ zabbix_clonehosts/assets/css/clonehosts.css | 1064 +++++++++++++++++ zabbix_clonehosts/assets/js/clonehosts.js.php | 1037 ++++++++++++++++ .../assets/js/clonehosts.preview.js.php | 253 ++++ zabbix_clonehosts/clonehosts_template.csv | 3 + zabbix_clonehosts/images/image-1.png | Bin 0 -> 135031 bytes zabbix_clonehosts/images/image-2.png | Bin 0 -> 104879 bytes zabbix_clonehosts/images/image-3.png | Bin 0 -> 126792 bytes zabbix_clonehosts/images/image-4.png | Bin 0 -> 58262 bytes zabbix_clonehosts/images/image.png | Bin 0 -> 47493 bytes zabbix_clonehosts/manifest.json | 37 + zabbix_clonehosts/views/clonehosts.import.php | 30 + zabbix_clonehosts/views/clonehosts.php | 211 ++++ .../views/clonehosts.preview.php | 314 +++++ 24 files changed, 4971 insertions(+) create mode 100644 zabbix_clonehosts/CompatHelper.php create mode 100644 zabbix_clonehosts/LangHelper.php create mode 100644 zabbix_clonehosts/Module.php create mode 100644 zabbix_clonehosts/README.md create mode 100644 zabbix_clonehosts/README_en.md create mode 100644 zabbix_clonehosts/actions/Clonehosts.php create mode 100644 zabbix_clonehosts/actions/ClonehostsImport.php create mode 100644 zabbix_clonehosts/actions/ClonehostsPreview.php create mode 100644 zabbix_clonehosts/actions/ClonehostsSource.php create mode 100644 zabbix_clonehosts/assets/css/clonehosts.css create mode 100644 zabbix_clonehosts/assets/js/clonehosts.js.php create mode 100644 zabbix_clonehosts/assets/js/clonehosts.preview.js.php create mode 100644 zabbix_clonehosts/clonehosts_template.csv create mode 100644 zabbix_clonehosts/images/image-1.png create mode 100644 zabbix_clonehosts/images/image-2.png create mode 100644 zabbix_clonehosts/images/image-3.png create mode 100644 zabbix_clonehosts/images/image-4.png create mode 100644 zabbix_clonehosts/images/image.png create mode 100644 zabbix_clonehosts/manifest.json create mode 100644 zabbix_clonehosts/views/clonehosts.import.php create mode 100644 zabbix_clonehosts/views/clonehosts.php create mode 100644 zabbix_clonehosts/views/clonehosts.preview.php diff --git a/README.md b/README.md index ed73294..2515183 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,32 @@ **兼容性**:Zabbix 6.0.x, 7.0.x, 7.4.x, 8.0.x +### 8. Zabbix Clonehosts + +**简介**:基于已有监控主机的配置批量克隆导入大量主机的 Zabbix 前端模块,支持 CSV 文件导入和在线表格录入两种方式,并提供预览、冲突检测、选择性导入和实时导入进度反馈功能。 + +**功能特性**: + +- 源主机克隆:选择任意已有监控主机作为克隆模板,其接口、群组、模板、标签、宏、TLS、IPMI、资产模式等全部配置均可被继承 +- 双模式数据录入:CSV 文件上传(UTF-8/GBK 自动识别,可下载模板)/ 在线表格录入(增删行、清空、实时校验) +- 智能字段继承:仅主机名称和接口 IP 必填,其他字段留空时自动继承源主机配置 +- 主机群组自动创建:CSV 中指定的主机群组若不存在,自动调用 API 创建后再关联 +- 预览与冲突检测:导入前全面预览,自动检测主机名冲突、必填字段缺失、批次内重复、与已有主机/模板同名冲突;状态标识区分「已存在」「将新建」「未找到」「继承自源主机」 +- 选择性导入:预览页支持勾选/取消要导入的主机,冲突主机自动跳过,就绪主机可单独取消导入 +- 返回编辑:预览页可一键带着数据返回在线表格录入页,方便对非就绪主机进行编辑,不会清空已录入数据 +- 导入进度反馈:逐台 AJAX 创建主机,实时进度条和成功/失败计数 +- 结果报告导出:导入完成后可下载 CSV 格式的结果报告(含主机名、IP、主机ID、结果、错误信息) +- 支持中英文界面国际化 + + + + + + +**文档链接**:[zabbix_clonehosts/README.md](./zabbix_clonehosts/README.md)([English](./zabbix_clonehosts/README_en.md)) + +**兼容性**:Zabbix 6.0.x, 7.0.x, 7.4.x, 8.0.x + ## 安装说明 ### 安装模块 @@ -209,6 +235,9 @@ sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_jumpserver/ma # 修改 zabbix_im 模块 sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_im/manifest.json + +# 修改 zabbix_clonehosts 模块 +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_clonehosts/manifest.json ``` 如果使用 Zabbix 7.0+ 或 8.0+,则无需修改,保持默认值即可。 @@ -236,6 +265,7 @@ sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_im/manifest.j - **数据采集 → SNMP Assistant** (SNMP 助手:Zabbix Mibs / Zabbix Walk) - **资产记录 → JumpServer** (JumpServer 同步) - **Users → IM同步助手** (IM 同步 / 同步设置) +- **数据采集 → 主机批量导入** (基于源主机批量克隆主机) ### 单独安装模块 diff --git a/README_en.md b/README_en.md index 8e7be19..a267aa0 100644 --- a/README_en.md +++ b/README_en.md @@ -90,6 +90,18 @@ This repository contains a collection of independent Zabbix frontend modules tha - **Docs**: [zabbix_im/README_en.md](./zabbix_im/README_en.md)([中文](./zabbix_im/README.md)) +### 8. Zabbix Clonehosts + +- **Purpose**: Batch-clone and import a large number of hosts based on an existing monitored host's configuration, with CSV upload and online table entry, plus preview, conflict detection, selective import and real-time progress feedback. +- **Features**: source host cloning (interfaces, groups, templates, tags, macros, TLS, IPMI, inventory_mode inheritable); dual-mode data entry (CSV upload with UTF-8/GBK auto-detection and template download / online table with add-remove rows and live validation); smart field inheritance (only host name and IP required, others fall back to source); auto-create missing host groups; full preview with conflict detection (host name conflicts, missing required fields, in-batch duplicates, name clashes with hosts/templates) and status badges (exists / new / not found / inherited); selective import via per-row checkboxes; back-to-edit from preview with all data preserved; sequential AJAX import with real-time progress bar and counters; CSV result report download (host name, IP, host ID, result, error); bilingual UI. + + + + + + +- **Docs**: [zabbix_clonehosts/README_en.md](./zabbix_clonehosts/README_en.md)([中文](./zabbix_clonehosts/README.md)) + ## Installation ### Deploy all modules (recommended) @@ -117,6 +129,7 @@ sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_rack/manifest sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_snmp/manifest.json sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_jumpserver/manifest.json sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_im/manifest.json +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_clonehosts/manifest.json ``` For Zabbix 7.0+ / 8.0+, no change is required. @@ -136,6 +149,7 @@ After enabling and refreshing the UI, the modules appear under the following men - **Data collection → SNMP Assistant** (Zabbix Mibs / Zabbix Walk) - **Inventory → JumpServer** - **Users → IM Sync Assistant** (IM Sync / Sync Settings) +- **Data collection → Host Batch Import** (batch clone hosts from a source host) Each module contains its own README with specific installation and usage details. diff --git a/zabbix_clonehosts/CompatHelper.php b/zabbix_clonehosts/CompatHelper.php new file mode 100644 index 0000000..428ba41 --- /dev/null +++ b/zabbix_clonehosts/CompatHelper.php @@ -0,0 +1,159 @@ +get([ + 'output' => [] + ]); + // If Settings API exists, it's 6.4+ + self::$major_version = 6; + // Check if it's 7.x by looking for newer API methods + if (method_exists(\API::Host(), 'get')) { + // Try a lightweight call to detect version behavior + // If selectHostGroups works, it's 6.4+ + self::$major_version = 7; // Default to 7 for safety + } + return self::$major_version; + } catch (\Exception $e) { + self::$major_version = 6; + return self::$major_version; + } + } + + $parts = explode('.', $version); + self::$major_version = (int) $parts[0]; + + return self::$major_version; + } + + /** + * Check if running on Zabbix 6.0 (which uses selectGroups). + * + * @return bool + */ + public static function isZabbix60(): bool { + return self::getMajorVersion() === 6 && self::getMinorVersion() === 0; + } + + /** + * Get the minor version. + * + * @return int + */ + public static function getMinorVersion(): int { + if (defined('ZABBIX_VERSION')) { + $parts = explode('.', ZABBIX_VERSION); + return (int) ($parts[1] ?? 0); + } + return 4; // Default assumption + } + + /** + * Get the correct select parameter for host groups. + * - Zabbix 6.0: 'selectGroups' + * - Zabbix 6.4+: 'selectHostGroups' + * + * @return string + */ + public static function getHostGroupSelectParam(): string { + if (self::isZabbix60()) { + return 'selectGroups'; + } + return 'selectHostGroups'; + } + + /** + * Get the correct result key for host groups in host data. + * - Zabbix 6.0: 'groups' + * - Zabbix 6.4+: 'hostgroups' + * + * @return string + */ + public static function getHostGroupResultKey(): string { + if (self::isZabbix60()) { + return 'groups'; + } + return 'hostgroups'; + } + + /** + * Normalize host data so that host groups are always under the 'hostgroups' key, + * regardless of Zabbix version. + * + * @param array $host Host data from API::Host()->get() + * @return array Normalized host data + */ + public static function normalizeHost(array $host): array { + $key = self::getHostGroupResultKey(); + + // If the key is 'groups' (Zabbix 6.0), rename to 'hostgroups' for consistency + if ($key === 'groups' && isset($host['groups']) && !isset($host['hostgroups'])) { + $host['hostgroups'] = $host['groups']; + unset($host['groups']); + } + + return $host; + } + + /** + * Build the API::Host()->get() parameters with version-correct host group select. + * + * @param array $baseParams Base parameters (output, hostids, selectInterfaces, etc.) + * @param array $groupFields Fields to select for host groups (e.g., ['groupid', 'name']) + * @return array Complete parameters with correct host group select + */ + public static function buildHostGetParams(array $baseParams, array $groupFields = ['groupid', 'name']): array { + $selectParam = self::getHostGroupSelectParam(); + $baseParams[$selectParam] = $groupFields; + return $baseParams; + } + + /** + * Get the correct manifest_version for the running Zabbix. + * - Zabbix 6.0: 1.0 + * - Zabbix 6.4+: 2.0 + * + * @return float + */ + public static function getManifestVersion(): float { + if (self::isZabbix60()) { + return 1.0; + } + return 2.0; + } +} diff --git a/zabbix_clonehosts/LangHelper.php b/zabbix_clonehosts/LangHelper.php new file mode 100644 index 0000000..0e83eaf --- /dev/null +++ b/zabbix_clonehosts/LangHelper.php @@ -0,0 +1,686 @@ + [en_GB => English, zh_CN => Chinese] */ + private static $translations = [ + // ===== Menu ===== + 'menu.clonehosts' => [ + 'en_GB' => 'Host Batch Import', + 'zh_CN' => '主机批量导入' + ], + + // ===== Step 1: Source Host Selection ===== + 'step1.title' => [ + 'en_GB' => 'Step 1: Select Source Host', + 'zh_CN' => '第一步:选择克隆源主机' + ], + 'step1.description' => [ + 'en_GB' => 'Select an existing monitored host. Its configuration (groups, templates, tags, macros, interfaces, TLS settings, etc.) will serve as the baseline template for all cloned hosts. Fields not specified in the import data will inherit values from this host.', + 'zh_CN' => '选择一个已有的监控主机。其配置(群组、模板、标签、宏、接口、TLS设置等)将作为所有克隆主机的基准模板。导入数据中未指定的字段将继承该主机的配置值。' + ], + 'step1.select_placeholder' => [ + 'en_GB' => '— Select Source Host —', + 'zh_CN' => '— 选择源主机 —' + ], + 'step1.load_btn' => [ + 'en_GB' => 'Load Source Host Info', + 'zh_CN' => '加载源主机信息' + ], + 'step1.loading' => [ + 'en_GB' => 'Loading...', + 'zh_CN' => '加载中...' + ], + 'step1.load_failed' => [ + 'en_GB' => 'Failed to load source host info', + 'zh_CN' => '加载源主机信息失败' + ], + 'step1.load_failed_network' => [ + 'en_GB' => 'Failed to load source host info, please check network connection.', + 'zh_CN' => '加载源主机信息失败,请检查网络连接。' + ], + 'step1.source_host_label' => [ + 'en_GB' => 'Source Host: ', + 'zh_CN' => '源主机:' + ], + 'step1.inherited_note' => [ + 'en_GB' => 'Fields not specified in the import data will inherit the above configuration.', + 'zh_CN' => '导入数据中未指定的字段将继承以上配置。' + ], + + // ===== Host Filter ===== + 'filter.host_group' => [ + 'en_GB' => 'Host Group', + 'zh_CN' => '主机群组' + ], + 'filter.all_groups' => [ + 'en_GB' => '— All Groups —', + 'zh_CN' => '— 全部群组 —' + ], + 'filter.host_name' => [ + 'en_GB' => 'Host Name', + 'zh_CN' => '主机名称' + ], + 'filter.name_placeholder' => [ + 'en_GB' => 'Search by host name...', + 'zh_CN' => '搜索主机名称...' + ], + 'filter.count' => [ + 'en_GB' => 'Showing {total} of {all} hosts', + 'zh_CN' => '显示 {total}/{all} 台主机' + ], + 'filter.no_match' => [ + 'en_GB' => 'No matching hosts found', + 'zh_CN' => '未找到匹配的主机' + ], + + // ===== Unified Search Box ===== + 'search.placeholder' => [ + 'en_GB' => 'Search hosts or host groups...', + 'zh_CN' => '搜索主机或主机群组...' + ], + 'search.hint' => [ + 'en_GB' => 'Type to search. Click a host or host group in the dropdown to filter the list below.', + 'zh_CN' => '输入关键字搜索。点击下拉框中的主机或主机群组,即可筛选下方列表。' + ], + 'search.category.host' => [ + 'en_GB' => 'Hosts', + 'zh_CN' => '主机' + ], + 'search.category.group' => [ + 'en_GB' => 'Host Groups', + 'zh_CN' => '主机群组' + ], + 'search.no_match' => [ + 'en_GB' => 'No matching data', + 'zh_CN' => '无匹配数据' + ], + 'search.remove' => [ + 'en_GB' => 'Remove', + 'zh_CN' => '移除' + ], + + // ===== Multiselect Control ===== + 'multiselect.search_placeholder' => [ + 'en_GB' => 'Type to search...', + 'zh_CN' => '输入关键字搜索...' + ], + 'multiselect.select_btn' => [ + 'en_GB' => 'Select', + 'zh_CN' => '选择' + ], + 'multiselect.popup_title_group' => [ + 'en_GB' => 'Select Host Groups', + 'zh_CN' => '选择主机群组' + ], + 'multiselect.popup_title_host' => [ + 'en_GB' => 'Select Hosts', + 'zh_CN' => '选择主机' + ], + 'multiselect.popup_search_placeholder' => [ + 'en_GB' => 'Search...', + 'zh_CN' => '搜索...' + ], + 'multiselect.popup_name_header' => [ + 'en_GB' => 'Name', + 'zh_CN' => '名称' + ], + 'multiselect.popup_count' => [ + 'en_GB' => '{count} selected', + 'zh_CN' => '已选 {count} 项' + ], + 'multiselect.popup_cancel' => [ + 'en_GB' => 'Cancel', + 'zh_CN' => '取消' + ], + 'multiselect.popup_apply' => [ + 'en_GB' => 'Apply', + 'zh_CN' => '应用' + ], + + // ===== Step 2: Data Input ===== + 'step2.title' => [ + 'en_GB' => 'Step 2: Enter Host Data', + 'zh_CN' => '第二步:录入主机数据' + ], + 'step2.description' => [ + 'en_GB' => 'Upload a CSV file or enter host data directly in the table below. Host name and interface IP are required; other fields are optional and will inherit from the source host when left blank.', + 'zh_CN' => '上传CSV文件或在下方表格中直接录入主机数据。主机名称和接口IP为必填项,其他字段为选填,留空时将继承源主机的配置。' + ], + 'step2.tab_file' => [ + 'en_GB' => 'File Upload (CSV)', + 'zh_CN' => '文件上传 (CSV)' + ], + 'step2.tab_table' => [ + 'en_GB' => 'Online Table Entry', + 'zh_CN' => '在线表格录入' + ], + + // ===== CSV Upload ===== + 'csv.parse_btn' => [ + 'en_GB' => 'Parse CSV File', + 'zh_CN' => '解析CSV文件' + ], + 'csv.download_template' => [ + 'en_GB' => 'Download CSV Template', + 'zh_CN' => '下载CSV模板' + ], + 'csv.encoding_hint' => [ + 'en_GB' => 'Tip: If Chinese characters in the CSV appear garbled, try switching encoding to GBK and re-parse. The template file uses UTF-8 encoding and can be opened directly in Excel.', + 'zh_CN' => '提示:若CSV文件中文显示乱码,请尝试切换编码为 GBK 后重新解析。模板文件使用 UTF-8 编码,可直接在 Excel 中打开编辑。' + ], + 'csv.no_data' => [ + 'en_GB' => 'No valid data rows found in CSV file.', + 'zh_CN' => 'CSV文件中未找到有效数据行。' + ], + 'csv.parsed_rows' => [ + 'en_GB' => 'Parsed: ', + 'zh_CN' => '解析:' + ], + 'csv.valid_rows' => [ + 'en_GB' => 'Valid: ', + 'zh_CN' => '有效:' + ], + 'csv.invalid_rows' => [ + 'en_GB' => 'Invalid (missing required fields): ', + 'zh_CN' => '无效(缺少必填字段):' + ], + 'csv.more_rows' => [ + 'en_GB' => '... ', + 'zh_CN' => '... 还有 ' + ], + 'csv.more_rows_suffix' => [ + 'en_GB' => ' more rows', + 'zh_CN' => ' 行' + ], + + // ===== CSV Headers ===== + 'csv.header.host' => [ + 'en_GB' => 'Host Name(*)', + 'zh_CN' => '主机名称(*)' + ], + 'csv.header.visible_name' => [ + 'en_GB' => 'Visible Name', + 'zh_CN' => '可见的名称' + ], + 'csv.header.ip' => [ + 'en_GB' => 'Interface IP(*)', + 'zh_CN' => '接口IP(*)' + ], + 'csv.header.port' => [ + 'en_GB' => 'Port', + 'zh_CN' => '端口' + ], + 'csv.header.groups' => [ + 'en_GB' => 'Host Groups', + 'zh_CN' => '主机群组' + ], + 'csv.header.templates' => [ + 'en_GB' => 'Templates', + 'zh_CN' => '模板' + ], + 'csv.header.tags' => [ + 'en_GB' => 'Tags', + 'zh_CN' => '标签' + ], + 'csv.header.macros' => [ + 'en_GB' => 'Macros', + 'zh_CN' => '宏' + ], + 'csv.header.description' => [ + 'en_GB' => 'Description', + 'zh_CN' => '描述' + ], + + // ===== Table Toolbar ===== + 'table.add_row' => [ + 'en_GB' => 'Add Row', + 'zh_CN' => '添加行' + ], + 'table.remove_row' => [ + 'en_GB' => 'Remove Selected Rows', + 'zh_CN' => '删除选中行' + ], + 'table.clear_all' => [ + 'en_GB' => 'Clear All', + 'zh_CN' => '清空全部' + ], + 'table.row_count' => [ + 'en_GB' => 'Total rows: ', + 'zh_CN' => '总行数:' + ], + 'table.clear_confirm' => [ + 'en_GB' => 'Are you sure you want to clear all rows?', + 'zh_CN' => '确定要清空所有行吗?' + ], + 'table.placeholder.required' => [ + 'en_GB' => 'Required', + 'zh_CN' => '必填' + ], + 'table.placeholder.inherited' => [ + 'en_GB' => '(inherited)', + 'zh_CN' => '(继承)' + ], + + // ===== Column Headers ===== + 'col.host_name' => [ + 'en_GB' => 'Host Name', + 'zh_CN' => '主机名称' + ], + 'col.visible_name' => [ + 'en_GB' => 'Visible Name', + 'zh_CN' => '可见的名称' + ], + 'col.interface_ip' => [ + 'en_GB' => 'Interface IP', + 'zh_CN' => '接口IP' + ], + 'col.port' => [ + 'en_GB' => 'Port', + 'zh_CN' => '端口' + ], + 'col.host_groups' => [ + 'en_GB' => 'Host Groups', + 'zh_CN' => '主机群组' + ], + 'col.templates' => [ + 'en_GB' => 'Templates', + 'zh_CN' => '模板' + ], + 'col.tags' => [ + 'en_GB' => 'Tags', + 'zh_CN' => '标签' + ], + 'col.macros' => [ + 'en_GB' => 'Macros', + 'zh_CN' => '宏' + ], + 'col.description' => [ + 'en_GB' => 'Description', + 'zh_CN' => '描述' + ], + 'col.status' => [ + 'en_GB' => 'Status', + 'zh_CN' => '状态' + ], + 'col.result' => [ + 'en_GB' => 'Result', + 'zh_CN' => '结果' + ], + 'col.host_id' => [ + 'en_GB' => 'Host ID', + 'zh_CN' => '主机ID' + ], + 'col.error' => [ + 'en_GB' => 'Error', + 'zh_CN' => '错误' + ], + + // ===== Preview ===== + 'preview.btn' => [ + 'en_GB' => 'Preview Import', + 'zh_CN' => '预览导入' + ], + 'preview.hint_no_data' => [ + 'en_GB' => 'Please fill in at least one valid row of host data to continue.', + 'zh_CN' => '请至少填写一行有效的主机数据以继续。' + ], + 'preview.hint_no_source' => [ + 'en_GB' => 'Please select a source host first.', + 'zh_CN' => '请先选择源主机。' + ], + 'preview.hint_ready' => [ + 'en_GB' => 'Ready to preview import.', + 'zh_CN' => '可以预览导入。' + ], + 'preview.alert_no_data' => [ + 'en_GB' => 'Please enter at least one valid host (host name and IP are required).', + 'zh_CN' => '请至少输入一台有效的主机(主机名和IP为必填项)。' + ], + + // ===== Preview Page ===== + 'preview.source_config' => [ + 'en_GB' => 'Source Host Configuration (will be inherited)', + 'zh_CN' => '源主机配置(将被继承)' + ], + 'preview.interfaces' => [ + 'en_GB' => 'Interfaces', + 'zh_CN' => '接口' + ], + 'preview.none' => [ + 'en_GB' => 'None', + 'zh_CN' => '无' + ], + 'preview.total' => [ + 'en_GB' => 'Total: ', + 'zh_CN' => '总计:' + ], + 'preview.conflicts' => [ + 'en_GB' => 'Conflicts: ', + 'zh_CN' => '冲突:' + ], + 'preview.importable' => [ + 'en_GB' => 'Importable: ', + 'zh_CN' => '可导入:' + ], + 'preview.pending_count' => [ + 'en_GB' => 'Hosts to import: ', + 'zh_CN' => '待导入主机数:' + ], + 'preview.no_conflicts' => [ + 'en_GB' => 'No conflicts detected', + 'zh_CN' => '未检测到冲突' + ], + 'preview.title' => [ + 'en_GB' => 'Host Import Preview', + 'zh_CN' => '主机导入预览' + ], + 'preview.inherited' => [ + 'en_GB' => '(inherited)', + 'zh_CN' => '(继承)' + ], + 'preview.ready' => [ + 'en_GB' => 'Ready', + 'zh_CN' => '就绪' + ], + 'preview.back' => [ + 'en_GB' => 'Back', + 'zh_CN' => '返回' + ], + 'preview.start_import' => [ + 'en_GB' => 'Start Import', + 'zh_CN' => '开始导入' + ], + 'preview.resolve_conflicts' => [ + 'en_GB' => 'Conflicting hosts will be skipped. You can uncheck ready hosts to exclude them too.', + 'zh_CN' => '冲突主机将被跳过,您也可以取消勾选就绪主机以排除。' + ], + 'preview.back_edit' => [ + 'en_GB' => 'Back to Edit', + 'zh_CN' => '返回编辑' + ], + 'preview.import_selected' => [ + 'en_GB' => 'Import Selected ({count})', + 'zh_CN' => '导入所选 ({count})' + ], + 'preview.no_selected' => [ + 'en_GB' => 'Please select at least one ready host to import.', + 'zh_CN' => '请至少勾选一台就绪主机进行导入。' + ], + 'preview.skipped' => [ + 'en_GB' => 'Skipped', + 'zh_CN' => '已跳过' + ], + 'preview.col_select' => [ + 'en_GB' => 'Import', + 'zh_CN' => '导入' + ], + + // ===== Field Status Legend ===== + 'legend.exists' => [ + 'en_GB' => 'Exists → Link directly', + 'zh_CN' => '已存在 → 直接关联' + ], + 'legend.new' => [ + 'en_GB' => 'Will be created', + 'zh_CN' => '将新建' + ], + 'legend.not_found' => [ + 'en_GB' => 'Not found', + 'zh_CN' => '未找到' + ], + 'legend.inherited' => [ + 'en_GB' => 'Inherited from source host', + 'zh_CN' => '继承自源主机' + ], + 'legend.exists_tooltip' => [ + 'en_GB' => 'Already exists, will be linked directly', + 'zh_CN' => '已存在,将直接关联' + ], + 'legend.new_tooltip_group' => [ + 'en_GB' => 'This host group will be created', + 'zh_CN' => '将新建此主机群组' + ], + 'legend.not_found_tooltip_template' => [ + 'en_GB' => 'Template not found, please verify the template name', + 'zh_CN' => '模板未找到,请确认模板名称是否正确' + ], + + // ===== Import Progress ===== + 'import.progress_title' => [ + 'en_GB' => 'Import Progress', + 'zh_CN' => '导入进度' + ], + 'import.importing' => [ + 'en_GB' => 'Importing...', + 'zh_CN' => '导入中...' + ], + 'import.complete' => [ + 'en_GB' => 'Import Complete', + 'zh_CN' => '导入完成' + ], + 'import.results_title' => [ + 'en_GB' => 'Import Results', + 'zh_CN' => '导入结果' + ], + 'import.success' => [ + 'en_GB' => 'Success: ', + 'zh_CN' => '成功:' + ], + 'import.failed' => [ + 'en_GB' => 'Failed: ', + 'zh_CN' => '失败:' + ], + 'import.download_report' => [ + 'en_GB' => 'Download Report', + 'zh_CN' => '下载报告' + ], + 'import.result_success' => [ + 'en_GB' => 'Success', + 'zh_CN' => '成功' + ], + 'import.result_failed' => [ + 'en_GB' => 'Failed', + 'zh_CN' => '失败' + ], + 'import.ajax_error' => [ + 'en_GB' => 'AJAX error: ', + 'zh_CN' => 'AJAX错误:' + ], + 'import.unknown_error' => [ + 'en_GB' => 'Unknown', + 'zh_CN' => '未知' + ], + + // ===== Report CSV Headers ===== + 'report.col_row' => [ + 'en_GB' => '#', + 'zh_CN' => '#' + ], + 'report.col_result' => [ + 'en_GB' => 'Result', + 'zh_CN' => '结果' + ], + 'report.col_host' => [ + 'en_GB' => 'Host Name', + 'zh_CN' => '主机名称' + ], + 'report.col_ip' => [ + 'en_GB' => 'Interface IP', + 'zh_CN' => '接口IP' + ], + 'report.col_hostid' => [ + 'en_GB' => 'Host ID', + 'zh_CN' => '主机ID' + ], + 'report.col_error' => [ + 'en_GB' => 'Error', + 'zh_CN' => '错误' + ], + + // ===== Error Messages (from controllers) ===== + 'err.host_required' => [ + 'en_GB' => 'Host name is required', + 'zh_CN' => '主机名称为必填项' + ], + 'err.ip_required' => [ + 'en_GB' => 'Interface IP is required', + 'zh_CN' => '接口IP为必填项' + ], + 'err.host_exists' => [ + 'en_GB' => 'This host already exists', + 'zh_CN' => '该主机已存在' + ], + 'err.template_exists' => [ + 'en_GB' => 'A template with the same name already exists', + 'zh_CN' => '同名模板已存在' + ], + 'err.source_not_found' => [ + 'en_GB' => 'Source host not found', + 'zh_CN' => '源主机未找到' + ], + 'err.no_groups' => [ + 'en_GB' => 'No host groups available (not specified and cannot inherit)', + 'zh_CN' => '没有可用的主机群组(未指定且无法继承)' + ], + 'err.unknown_api_response' => [ + 'en_GB' => 'Unknown API response', + 'zh_CN' => '未知的API响应' + ], + 'err.batch_duplicate' => [ + 'en_GB' => 'Duplicate host name within batch', + 'zh_CN' => '批次内主机名称重复' + ], + 'err.host_exists_in_zabbix' => [ + 'en_GB' => 'This host name already exists in Zabbix', + 'zh_CN' => '该主机名称在Zabbix中已存在' + ], + + // ===== Interface Type Names ===== + 'iface.Agent' => [ + 'en_GB' => 'Agent', + 'zh_CN' => 'Agent' + ], + 'iface.SNMP' => [ + 'en_GB' => 'SNMP', + 'zh_CN' => 'SNMP' + ], + 'iface.IPMI' => [ + 'en_GB' => 'IPMI', + 'zh_CN' => 'IPMI' + ], + 'iface.JMX' => [ + 'en_GB' => 'JMX', + 'zh_CN' => 'JMX' + ], + 'iface.Type' => [ + 'en_GB' => 'Type ', + 'zh_CN' => 'Type ' + ], + ]; + + /** + * Detect the current user's language from Zabbix. + * Priority: CWebUser::$data['lang'] → API user.get → default en_GB + * + * @return string Language code (e.g., 'zh_CN', 'en_GB') + */ + public static function getLang(): string { + if (self::$lang !== null) { + return self::$lang; + } + + // Method 1: Try CWebUser (available in Zabbix frontend context) + if (class_exists('CWebUser', false) && isset(\CWebUser::$data['lang'])) { + self::$lang = \CWebUser::$data['lang']; + return self::$lang; + } + + // Method 2: Try to get from the session/API + try { + $user = \API::User()->get([ + 'output' => ['lang'], + 'userids' => [\CWebUser::$data['userid'] ?? 0] + ]); + if (!empty($user) && !empty($user[0]['lang'])) { + self::$lang = $user[0]['lang']; + return self::$lang; + } + } catch (\Exception $e) { + // Fall through to default + } + + // Default: English + self::$lang = 'en_GB'; + return self::$lang; + } + + /** + * Check if the current language is Chinese. + * + * @return bool + */ + public static function isChinese(): bool { + return self::getLang() === 'zh_CN' || strpos(self::getLang(), 'zh') === 0; + } + + /** + * Translate a key to the current language, with optional parameter substitution. + * Supports {placeholder} style parameters in the translation string. + * + * @param string $key Translation key + * @param array $params Optional ['{placeholder}' => 'value'] pairs for substitution + * @return string Translated string (falls back to English, then to the key itself) + */ + public static function t(string $key, array $params = []): string { + $lang = self::getLang(); + + if (isset(self::$translations[$key][$lang])) { + $str = self::$translations[$key][$lang]; + } elseif (isset(self::$translations[$key]['en_GB'])) { + $str = self::$translations[$key]['en_GB']; + } else { + $str = $key; + } + + if (!empty($params)) { + $str = strtr($str, $params); + } + + return $str; + } + + /** + * Get all translations for the current language as a JSON-serializable array. + * Used to pass translations to JavaScript. + * + * @return array Key => translated string + */ + public static function getAllForJs(): array { + $lang = self::getLang(); + $result = []; + + foreach (self::$translations as $key => $langs) { + if (isset($langs[$lang])) { + $result[$key] = $langs[$lang]; + } elseif (isset($langs['en_GB'])) { + $result[$key] = $langs['en_GB']; + } + } + + return $result; + } +} diff --git a/zabbix_clonehosts/Module.php b/zabbix_clonehosts/Module.php new file mode 100644 index 0000000..485a6b1 --- /dev/null +++ b/zabbix_clonehosts/Module.php @@ -0,0 +1,53 @@ +get('menu.main') + ->findOrAdd(_('Data collection')) + ->getSubmenu() + ->insertAfter(_('Hosts'), + (new CMenuItem($menu_label))->setAction('clonehosts') + ); + } +} + +// Compatibility: Zabbix 6.4+ uses Zabbix\Core\CModule, Zabbix 6.0 uses Core\CModule +if (class_exists('Zabbix\\Core\\CModule', false)) { + class Module extends \Zabbix\Core\CModule { + use ModuleInitTrait; + } +} else { + class Module extends \Core\CModule { + use ModuleInitTrait; + } +} diff --git a/zabbix_clonehosts/README.md b/zabbix_clonehosts/README.md new file mode 100644 index 0000000..94ff81c --- /dev/null +++ b/zabbix_clonehosts/README.md @@ -0,0 +1,133 @@ +# Zabbix Clonehosts 模块 + +[English](README_en.md) + +## ✨ 版本兼容性 + +本模块兼容 Zabbix 6.0 / 7.0+ / 8.0+ 版本。 + +- ✅ Zabbix 6.0.x +- ✅ Zabbix 7.0.x +- ✅ Zabbix 7.4.x +- ✅ Zabbix 8.0.x + +**兼容性说明**:模块内置智能版本检测机制(`CompatHelper`),自动适配不同版本的 Zabbix API 参数差异(如 6.0 的 `selectGroups` 与 6.4+ 的 `selectHostGroups`、`groups` 与 `hostgroups` 结果键差异、`Zabbix\Core\CModule` 与 `Core\CModule` 命名空间差异等),无需手动配置。语言助手 `LangHelper` 会自动跟随 Zabbix 系统语言设置切换中/英文界面。 + +## 描述 + +这是一个 Zabbix 前端模块,用于基于已有监控主机的配置批量克隆导入大量主机。模块在 Zabbix Web 的「数据采集(Data collection)」菜单下新增「主机批量导入」菜单项(位于「主机」之后),支持 CSV 文件导入和在线表格录入两种方式,并提供预览、冲突检测和实时导入进度反馈功能。 + + + + + + +## 功能特性 + +- **源主机克隆**:选择任意已有监控主机作为克隆模板,其全部配置(接口、群组、模板、标签、宏、TLS、IPMI、资产模式等)均可被继承 + +- **双模式数据录入**: + - CSV 文件上传:支持 UTF-8 和 GBK 编码,自动表头检测和编码识别,可下载 CSV 模板 + - 在线表格录入:支持添加/删除行、清空全部,数据实时校验 + +- **智能字段继承**:仅主机名称和接口 IP 为必填项,其他字段(可见名称、端口、主机群组、模板、标签、宏、描述)留空时自动继承源主机配置 + +- **主机群组自动创建**:导入时 CSV 中指定的主机群组若不存在,自动调用 API 创建后再关联 + +- **预览与冲突检测**:导入前全面预览,自动检测主机名冲突、必填字段缺失、批次内重复、与已有主机/模板同名冲突等问题;并以状态标识区分「已存在直接关联」「将新建」「未找到」「继承自源主机」 + +- **选择性导入**:预览页支持勾选/取消要导入的主机,冲突主机自动跳过,就绪主机可单独取消导入 + +- **返回编辑**:预览页可一键带着数据返回在线表格录入页,方便对非就绪主机进行编辑,不会清空已录入数据 + +- **导入进度反馈**:逐台 AJAX 创建主机,实时进度条和成功/失败计数 + +- **结果报告导出**:导入完成后可下载 CSV 格式的结果报告(含主机名、IP、主机ID、结果、错误信息) + +- **中英文双语支持**:界面语言自动跟随 Zabbix 系统设置(`zh_CN` / `en_GB`),无需 gettext 依赖 + +- **响应式设计**:适配不同屏幕尺寸 + +- **现代化界面**:遵循 Zabbix 原生设计风格 + +## 安装步骤 + +### 安装模块 + +```bash +# Zabbix 6.0 / 7.0 部署方法 +git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ + +# Zabbix 7.4 / 8.0 部署方法 +git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ +``` + +### ⚠️ 修改 manifest.json 文件 + +```bash +# ⚠️ 如果使用Zabbix 6.0,修改manifest_version +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_clonehosts/manifest.json +``` + +### 启用模块 + +1. 转到 **Administration → General → Modules**。 +2. 点击 **Scan directory** 按钮扫描新模块。 +3. 找到「Host Batch Import / 主机批量导入」模块,点击启用模块。 +4. 刷新页面,模块将在 **Data collection(数据采集)** 菜单下显示为「主机批量导入」,位于「Hosts(主机)」之后。 + +## CSV 数据格式 + +模块附带模板文件 `clonehosts_template.csv`,可在导入页面点击「下载 CSV 模板」获取。CSV 表头及字段说明: + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| 主机名称(*) | 是 | 主机标识,需全局唯一(与模板共享命名空间) | +| 可见的名称 | 否 | 留空时使用主机名称 | +| 接口IP(*) | 是 | 主机接口 IP 地址 | +| 端口 | 否 | 留空时继承源主机端口 | +| 主机群组 | 否 | 多个群组用 `;` 分隔,不存在时自动创建 | +| 模板 | 否 | 多个模板用 `;` 分隔,按 host 或 name 匹配 | +| 标签 | 否 | 格式 `tag=value`,多个用 `;` 分隔 | +| 宏 | 否 | 格式 `{$MACRO}=value`,多个用 `;` 分隔 | +| 描述 | 否 | 主机描述信息 | + +示例: + +```csv +主机名称(*),可见的名称,接口IP(*),端口,主机群组,模板,标签,宏,描述 +web-server-01,Web Server 01,192.168.1.10,10050,Servers;Web Servers,Linux by Zabbix Agent;Nginx by HTTP,env=prod;os=linux,{$SNMP_COMMUNITY}=public,Web server 01 +db-server-01,DB Server 01,192.168.1.20,10050,Servers;Database Servers,MySQL by Zabbix Agent,env=prod;os=linux;role=db,{$MYSQL_PORT}=3306,Database server 01 +``` + +## 注意事项 + +- **性能考虑**:导入采用逐台串行方式,大批量导入可能需要较长时间。建议单次导入不超过 500 台主机。 +- **主机名唯一性**:主机名在 Zabbix 中与模板名共享命名空间,必须全局唯一。预览阶段会同时检测主机和模板冲突。 +- **模板依赖**:CSV 中指定的模板必须在 Zabbix 中已存在,否则导入时仅关联已存在的模板(未找到的模板会被标记为「未找到」)。 +- **CSV 编码**:建议使用 UTF-8 编码保存 CSV 文件。若中文显示乱码,可在页面切换编码为 GBK 后重新解析。 +- **权限要求**:使用本模块需要 Zabbix 管理员(Zabbix Admin)及以上权限。 +- **数据准确性**:创建的监控主机基于源主机的当前配置快照。如果源主机在导入过程中被修改,已创建的主机不会受影响。 + +## 开发 + +模块基于 Zabbix 模块框架开发。文件结构: + +- `manifest.json`:模块配置、路由和静态资源声明 +- `Module.php`:菜单注册(兼容 `Zabbix\Core\CModule` 与 `Core\CModule`) +- `CompatHelper.php`:Zabbix 6.0/6.4/7.x/8.x API 兼容性辅助类 +- `LangHelper.php`:中英文国际化语言管理(纯 PHP 数组实现,无 gettext 依赖) +- `actions/Clonehosts.php`:主页面控制器(源主机选择、CSV 上传、表格录入) +- `actions/ClonehostsSource.php`:AJAX 源主机配置加载接口 +- `actions/ClonehostsPreview.php`:预览页面控制器(冲突检测、字段状态) +- `actions/ClonehostsImport.php`:AJAX 导入接口(逐台创建主机) +- `views/`:页面视图(主页面、预览、JSON 响应) +- `assets/js/`:JavaScript(CSV 解析、表格管理、AJAX 导入进度) +- `assets/css/`:模块样式表 +- `clonehosts_template.csv`:CSV 导入模板 + +如需扩展,可参考 [Zabbix 模块开发文档](https://www.zabbix.com/documentation/current/zh/devel/modules/file_structure)。 + +## 许可证 + +本项目遵循 GPL-2.0 许可证。 diff --git a/zabbix_clonehosts/README_en.md b/zabbix_clonehosts/README_en.md new file mode 100644 index 0000000..728fb1a --- /dev/null +++ b/zabbix_clonehosts/README_en.md @@ -0,0 +1,123 @@ +# Zabbix Clonehosts Module + +[中文](README.md) + +## ✨ Version Compatibility + +This module is compatible with Zabbix 6.0 / 7.0+ / 8.0+. + +- ✅ Zabbix 6.0.x +- ✅ Zabbix 7.0.x +- ✅ Zabbix 7.4.x +- ✅ Zabbix 8.0.x + +**Compatibility Note**: The module includes an automatic version detection layer (`CompatHelper`) that adapts to different Zabbix API versions and library namespaces (e.g. `selectGroups` vs `selectHostGroups`, `groups` vs `hostgroups` result keys, `Zabbix\Core\CModule` vs `Core\CModule`), so no manual configuration is required. The `LangHelper` helper automatically follows the Zabbix system language to switch between Chinese and English. + +## Description + +Zabbix Clonehosts is a frontend module for Zabbix that batch-clones and imports a large number of hosts based on an existing monitored host's configuration. It adds a "Host Batch Import" menu item under the "Data collection" menu in Zabbix Web (after "Hosts"), supporting both CSV file import and online table entry, with preview, conflict detection, and real-time import progress feedback. + + + + + + +## Features + +- **Source host cloning**: Pick any existing monitored host as the clone template. Its full configuration (interfaces, groups, templates, tags, macros, TLS, IPMI, inventory mode, etc.) can be inherited. +- **Dual-mode data entry**: + - CSV upload: UTF-8 and GBK supported, automatic header detection and encoding recognition, CSV template download available. + - Online table entry: add/remove rows, clear all, real-time validation. +- **Smart field inheritance**: Only host name and interface IP are required. Other fields (visible name, port, host groups, templates, tags, macros, description) fall back to the source host's config when left empty. +- **Host group auto-creation**: Host groups specified in the CSV that do not yet exist are created via the API before being linked. +- **Preview & conflict detection**: Full preview before import; auto-detects host name conflicts, missing required fields, in-batch duplicates, and name clashes with existing hosts/templates. Status badges distinguish "exists / direct link", "will create", "not found", and "inherited from source". +- **Selective import**: The preview page lets you check/uncheck the hosts you want to import. Conflicting hosts are skipped automatically; ready hosts can also be excluded individually. +- **Back to edit**: From the preview page you can return to the online table page with all data preserved, so non-ready hosts can be edited without losing what was already entered. +- **Import progress feedback**: Hosts are created one by one via AJAX, with a real-time progress bar and success/fail counters. +- **Result report export**: After import finishes, you can download a CSV report (host name, IP, host ID, result, error message). +- **Bilingual UI**: The interface follows the Zabbix system language (`zh_CN` / `en_GB`), no gettext dependency. +- **Responsive design**: Adapts to different screen sizes. +- **Modern UI**: Follows the native Zabbix design language. + +## Installation + +### Install the module + +```bash +# For Zabbix 6.0 / 7.0 deployment +git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ + +# For Zabbix 7.4 / 8.0 deployment +git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ +``` + +### ⚠️ Modify `manifest.json` + +If you are using Zabbix 6.0, change the `manifest_version` in `zabbix_clonehosts/manifest.json` to `1.0`: + +```bash +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_clonehosts/manifest.json +``` + +### Enable the module + +1. Go to Administration → General → Modules. +2. Click **Scan directory** to detect new modules. +3. Find and enable the "Host Batch Import / 主机批量导入" module. +4. Refresh the page — the module appears under **Data collection → Host Batch Import**, right after "Hosts". + +## CSV format + +The module ships with a template file `clonehosts_template.csv`. You can download it from the import page via the "Download CSV template" button. CSV header and field descriptions: + +| Field | Required | Description | +| --- | --- | --- | +| 主机名称(*) | Yes | Host identifier, must be globally unique (shares namespace with templates) | +| 可见的名称 | No | Falls back to host name when empty | +| 接口IP(*) | Yes | Host interface IP address | +| 端口 | No | Falls back to source host's port when empty | +| 主机群组 | No | Multiple groups separated by `;`; auto-created if missing | +| 模板 | No | Multiple templates separated by `;`; matched by host or name | +| 标签 | No | Format `tag=value`, multiple pairs separated by `;` | +| 宏 | No | Format `{$MACRO}=value`, multiple pairs separated by `;` | +| 描述 | No | Host description | + +Example: + +```csv +主机名称(*),可见的名称,接口IP(*),端口,主机群组,模板,标签,宏,描述 +web-server-01,Web Server 01,192.168.1.10,10050,Servers;Web Servers,Linux by Zabbix Agent;Nginx by HTTP,env=prod;os=linux,{$SNMP_COMMUNITY}=public,Web server 01 +db-server-01,DB Server 01,192.168.1.20,10050,Servers;Database Servers,MySQL by Zabbix Agent,env=prod;os=linux;role=db,{$MYSQL_PORT}=3306,Database server 01 +``` + +## Notes + +- **Performance**: Import is sequential. Large batches may take a while. Recommended upper bound: ~500 hosts per run. +- **Host name uniqueness**: Host names share the namespace with templates in Zabbix and must be globally unique. The preview stage checks both host and template conflicts. +- **Template dependency**: Templates referenced in the CSV must already exist in Zabbix. Missing templates are marked as "not found" and skipped during import. +- **CSV encoding**: UTF-8 is recommended. If Chinese characters look garbled, switch the page encoding to GBK and re-parse. +- **Permissions**: Using this module requires Zabbix Admin or higher. +- **Data accuracy**: Created hosts are based on a snapshot of the source host's current configuration. Changes to the source host during import do not affect already-created hosts. + +## Development + +The module is built on the Zabbix module framework. Key files: + +- `manifest.json` — module configuration, routes and asset declarations +- `Module.php` — menu registration (compatible with both `Zabbix\Core\CModule` and `Core\CModule`) +- `CompatHelper.php` — Zabbix 6.0/6.4/7.x/8.x API compatibility helper +- `LangHelper.php` — Chinese/English i18n (pure PHP array, no gettext) +- `actions/Clonehosts.php` — main page controller (source host selection, CSV upload, table entry) +- `actions/ClonehostsSource.php` — AJAX endpoint for loading source host config +- `actions/ClonehostsPreview.php` — preview page controller (conflict detection, field status) +- `actions/ClonehostsImport.php` — AJAX import endpoint (creates hosts one by one) +- `views/` — page views (main page, preview, JSON response) +- `assets/js/` — JavaScript (CSV parsing, table management, AJAX import progress) +- `assets/css/` — module styles +- `clonehosts_template.csv` — CSV import template + +See Zabbix module development docs for extension: https://www.zabbix.com/documentation/current/en/devel/modules/file_structure + +## License + +This project is licensed under GPL-2.0. diff --git a/zabbix_clonehosts/actions/Clonehosts.php b/zabbix_clonehosts/actions/Clonehosts.php new file mode 100644 index 0000000..1d2a78d --- /dev/null +++ b/zabbix_clonehosts/actions/Clonehosts.php @@ -0,0 +1,127 @@ +disableCsrfValidation(); + } + if (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + } + + protected function checkInput(): bool { + $ret = $this->validateInput([ + 'source_hostid' => 'string', + 'host_data' => 'string', + 'return_from_preview' => 'in 1' + ]); + return true; + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + // Get all monitored hosts for the source host dropdown. + // Include host group membership so the frontend can filter by group. + $host_get_params = [ + 'output' => ['hostid', 'host', 'name'], + 'filter' => ['status' => HOST_STATUS_MONITORED], + 'sortfield' => 'name', + 'preservekeys' => true + ]; + $host_get_params = CompatHelper::buildHostGetParams($host_get_params); + + $hosts = API::Host()->get($host_get_params); + + // Normalize host data (Zabbix 6.0 uses 'groups', 6.4+ uses 'hostgroups'). + // Build a hostid → [groupid, groupid, ...] mapping for frontend filtering. + $host_group_map = []; + $group_result_key = CompatHelper::getHostGroupResultKey(); + foreach ($hosts as $hostid => $host) { + $host = CompatHelper::normalizeHost($host); + $host_group_map[$hostid] = []; + if (isset($host['hostgroups'])) { + foreach ($host['hostgroups'] as $group) { + $host_group_map[$hostid][] = $group['groupid']; + } + } + $hosts[$hostid] = $host; + } + + // Get all host groups for reference. + $host_groups = API::HostGroup()->get([ + 'output' => ['groupid', 'name'], + 'sortfield' => 'name', + 'preservekeys' => true + ]); + + // Get all templates for reference. + // Zabbix 6.0: API::Template() exists but may have slightly different behavior. + $templates = API::Template()->get([ + 'output' => ['templateid', 'host', 'name'], + 'sortfield' => 'name', + 'preservekeys' => true + ]); + + $data = [ + 'hosts' => $hosts, + 'host_groups' => $host_groups, + 'host_group_map' => $host_group_map, + 'templates' => $templates, + 'lang' => LangHelper::getAllForJs() + ]; + + // Handle return-from-preview: carry back source host + table data so the + // online table can be restored instead of being cleared. + $return_source_hostid = $this->getInput('source_hostid', ''); + $return_host_data_raw = $this->getInput('host_data', ''); + $return_from_preview = $this->getInput('return_from_preview', ''); + + if ($return_from_preview === '1') { + $return_host_data = json_decode($return_host_data_raw, true); + if (is_array($return_host_data)) { + $data['return_source_hostid'] = $return_source_hostid; + $data['return_host_data'] = $return_host_data; + } + } + + $response = new CControllerResponseData($data); + $this->setResponse($response); + } +} diff --git a/zabbix_clonehosts/actions/ClonehostsImport.php b/zabbix_clonehosts/actions/ClonehostsImport.php new file mode 100644 index 0000000..5e35ee4 --- /dev/null +++ b/zabbix_clonehosts/actions/ClonehostsImport.php @@ -0,0 +1,357 @@ +disableCsrfValidation(); + } + if (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + } + + protected function checkInput(): bool { + $ret = $this->validateInput([ + 'source_hostid' => 'required|string', + 'host_data' => 'required|array' + ]); + + if (!$ret) { + $this->setResponse(new CControllerResponseFatal()); + } + + return $ret; + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + $source_hostid = $this->getInput('source_hostid'); + $host_input = $this->getInput('host_data'); + + $host_name = trim($host_input['host'] ?? ''); + $ip = trim($host_input['ip'] ?? ''); + $visible_name = trim($host_input['visible_name'] ?? ''); + $port = trim($host_input['port'] ?? ''); + $description = trim($host_input['description'] ?? ''); + $groups_input = trim($host_input['groups'] ?? ''); + $templates_input = trim($host_input['templates'] ?? ''); + $tags_input = trim($host_input['tags'] ?? ''); + $macros_input = trim($host_input['macros'] ?? ''); + + $result = [ + 'host' => $host_name, + 'ip' => $ip, + 'success' => false, + 'error' => '', + 'hostid' => '' + ]; + + // Validate required fields. + if ($host_name === '') { + $result['error'] = LangHelper::t('err.host_required'); + $this->sendResponse($result); + return; + } + + if ($ip === '') { + $result['error'] = LangHelper::t('err.ip_required'); + $this->sendResponse($result); + return; + } + + // Check if host already exists. + $existing = API::Host()->get([ + 'output' => ['hostid'], + 'filter' => ['host' => [$host_name]] + ]); + + if (!empty($existing)) { + $result['error'] = LangHelper::t('err.host_exists'); + $this->sendResponse($result); + return; + } + + // Check if template with same name exists. + $existing_tpl = API::Template()->get([ + 'output' => ['templateid'], + 'filter' => ['host' => [$host_name]] + ]); + + if (!empty($existing_tpl)) { + $result['error'] = LangHelper::t('err.template_exists'); + $this->sendResponse($result); + return; + } + + // Load the source host's full configuration for inheritance. + // Use CompatHelper for version-correct host group select parameter. + $host_get_params = [ + 'output' => ['hostid', 'host', 'name', 'status', 'description', + 'inventory_mode', 'tls_connect', 'tls_accept', 'tls_issuer', + 'tls_subject', 'ipmi_authtype', 'ipmi_privilege', + 'ipmi_username', 'ipmi_password' + ], + 'hostids' => [$source_hostid], + 'selectInterfaces' => ['type', 'main', 'useip', 'ip', 'dns', 'port', 'details'], + 'selectParentTemplates' => ['templateid', 'host', 'name'], + 'selectTags' => ['tag', 'value'], + 'selectMacros' => ['macro', 'value', 'description', 'type'] + ]; + $host_get_params = CompatHelper::buildHostGetParams($host_get_params); + + $source_host = API::Host()->get($host_get_params); + + if (empty($source_host)) { + $result['error'] = LangHelper::t('err.source_not_found'); + $this->sendResponse($result); + return; + } + + $source_host = CompatHelper::normalizeHost($source_host[0]); + + // --- Build the host creation payload, inheriting from source host --- + + // 1. Interfaces: use source host's interface as template, override IP and port. + $interfaces = []; + if (!empty($source_host['interfaces'])) { + foreach ($source_host['interfaces'] as $src_iface) { + $iface = [ + 'type' => (int) $src_iface['type'], + 'main' => (int) $src_iface['main'], + 'useip' => (int) $src_iface['useip'], + 'ip' => $ip, + 'dns' => $src_iface['useip'] ? ($src_iface['dns'] ?? '') : $ip, + 'port' => $port !== '' ? $port : $src_iface['port'] + ]; + if (isset($src_iface['details']) && !empty($src_iface['details'])) { + $iface['details'] = $src_iface['details']; + } + $interfaces[] = $iface; + } + } else { + // Default: Zabbix agent interface. + $interfaces[] = [ + 'type' => 1, + 'main' => 1, + 'useip' => 1, + 'ip' => $ip, + 'dns' => '', + 'port' => $port !== '' ? $port : '10050' + ]; + } + + // 2. Host groups: use specified groups or inherit from source host. + $groups = []; + if ($groups_input !== '') { + $group_names = array_filter(array_map('trim', explode(';', $groups_input))); + if (!empty($group_names)) { + // Look up existing groups. + $db_groups = API::HostGroup()->get([ + 'output' => ['groupid', 'name'], + 'filter' => ['name' => $group_names] + ]); + $found_group_names = array_column($db_groups, 'name'); + + foreach ($db_groups as $dg) { + $groups[] = ['groupid' => $dg['groupid']]; + } + + // Auto-create any groups that don't exist yet. + $missing_groups = array_diff($group_names, $found_group_names); + foreach ($missing_groups as $mg_name) { + try { + $created = API::HostGroup()->create(['name' => $mg_name]); + if (isset($created['groupids'][0])) { + $groups[] = ['groupid' => $created['groupids'][0]]; + } + } catch (\Exception $e) { + // If group creation fails, skip it silently. + // The group will simply not be added. + } + } + } + } + if (empty($groups) && !empty($source_host['hostgroups'])) { + foreach ($source_host['hostgroups'] as $sg) { + $groups[] = ['groupid' => $sg['groupid']]; + } + } + + if (empty($groups)) { + $result['error'] = LangHelper::t('err.no_groups'); + $this->sendResponse($result); + return; + } + + // 3. Templates: use specified templates or inherit from source host. + $templates = []; + if ($templates_input !== '') { + $template_names = array_filter(array_map('trim', explode(';', $templates_input))); + if (!empty($template_names)) { + $db_templates = API::Template()->get([ + 'output' => ['templateid', 'host', 'name'], + 'filter' => ['host' => $template_names] + ]); + // Also try matching by visible name. + $found_hosts = array_column($db_templates, 'host'); + $missing = array_diff($template_names, $found_hosts); + if (!empty($missing)) { + $db_templates_by_name = API::Template()->get([ + 'output' => ['templateid', 'host', 'name'], + 'filter' => ['name' => array_values($missing)] + ]); + $db_templates = array_merge($db_templates, $db_templates_by_name); + } + foreach ($db_templates as $dt) { + $templates[] = ['templateid' => $dt['templateid']]; + } + } + } + if (empty($templates) && !empty($source_host['parentTemplates'])) { + foreach ($source_host['parentTemplates'] as $st) { + $templates[] = ['templateid' => $st['templateid']]; + } + } + + // 4. Tags: use specified tags or inherit from source host. + $tags = []; + if ($tags_input !== '') { + $tag_pairs = array_filter(array_map('trim', explode(';', $tags_input))); + foreach ($tag_pairs as $pair) { + $parts = explode('=', $pair, 2); + if (count($parts) === 2) { + $tags[] = [ + 'tag' => trim($parts[0]), + 'value' => trim($parts[1]) + ]; + } elseif (count($parts) === 1 && trim($parts[0]) !== '') { + $tags[] = [ + 'tag' => trim($parts[0]), + 'value' => '' + ]; + } + } + } + if (empty($tags) && !empty($source_host['tags'])) { + foreach ($source_host['tags'] as $st) { + $tags[] = [ + 'tag' => $st['tag'], + 'value' => $st['value'] ?? '' + ]; + } + } + + // 5. Macros: use specified macros or inherit from source host. + $macros = []; + if ($macros_input !== '') { + $macro_pairs = array_filter(array_map('trim', explode(';', $macros_input))); + foreach ($macro_pairs as $pair) { + $parts = explode('=', $pair, 2); + if (count($parts) === 2) { + $macros[] = [ + 'macro' => trim($parts[0]), + 'value' => trim($parts[1]), + 'type' => 0 + ]; + } + } + } + if (empty($macros) && !empty($source_host['macros'])) { + foreach ($source_host['macros'] as $sm) { + $macros[] = [ + 'macro' => $sm['macro'], + 'value' => $sm['value'] ?? '', + 'type' => (int) ($sm['type'] ?? 0) + ]; + if (isset($sm['description'])) { + $macros[count($macros) - 1]['description'] = $sm['description']; + } + } + } + + // 6. Build the final host creation array. + $create_host = [ + 'host' => $host_name, + 'name' => $visible_name !== '' ? $visible_name : $host_name, + 'status' => (int) $source_host['status'], + 'interfaces' => $interfaces, + 'groups' => $groups, + 'templates' => $templates, + 'tags' => $tags, + 'macros' => $macros, + 'inventory_mode' => (int) $source_host['inventory_mode'], + 'description' => $description !== '' ? $description : ($source_host['description'] ?? ''), + 'tls_connect' => (int) $source_host['tls_connect'], + 'tls_accept' => (int) $source_host['tls_accept'] + ]; + + if (!empty($source_host['tls_issuer'])) { + $create_host['tls_issuer'] = $source_host['tls_issuer']; + } + if (!empty($source_host['tls_subject'])) { + $create_host['tls_subject'] = $source_host['tls_subject']; + } + + // 7. Create the host via API. + try { + $created = API::Host()->create($create_host); + + if (isset($created['hostids'][0])) { + $result['success'] = true; + $result['hostid'] = $created['hostids'][0]; + } else { + $result['error'] = LangHelper::t('err.unknown_api_response'); + } + } catch (\Exception $e) { + $result['error'] = $e->getMessage(); + } + + $this->sendResponse($result); + } + + /** + * Set JSON response via CControllerResponseData. + * The view (clonehosts.import.php) outputs the data as JSON. + */ + private function sendResponse(array $data): void { + $this->setResponse(new CControllerResponseData($data)); + } +} diff --git a/zabbix_clonehosts/actions/ClonehostsPreview.php b/zabbix_clonehosts/actions/ClonehostsPreview.php new file mode 100644 index 0000000..a0dd7dd --- /dev/null +++ b/zabbix_clonehosts/actions/ClonehostsPreview.php @@ -0,0 +1,247 @@ +disableCsrfValidation(); + } + if (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + } + + protected function checkInput(): bool { + $ret = $this->validateInput([ + 'source_hostid' => 'required|string', + 'host_data' => 'required|string' + ]); + + if (!$ret) { + $this->setResponse(new CControllerResponseFatal()); + } + + return $ret; + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + $source_hostid = $this->getInput('source_hostid'); + $host_data_json = $this->getInput('host_data'); + $host_data = json_decode($host_data_json, true); + + if (!is_array($host_data)) { + $this->setResponse(new CControllerResponseFatal()); + return; + } + + // Load the source host's full configuration for inheritance. + // Use CompatHelper for version-correct host group select parameter. + $host_get_params = [ + 'output' => ['hostid', 'host', 'name', 'status', 'description', + 'inventory_mode', 'tls_connect', 'tls_accept', 'tls_issuer', + 'tls_subject', 'ipmi_authtype', 'ipmi_privilege', + 'ipmi_username', 'ipmi_password' + ], + 'hostids' => [$source_hostid], + 'selectInterfaces' => ['type', 'main', 'useip', 'ip', 'dns', 'port', 'details'], + 'selectParentTemplates' => ['templateid', 'host', 'name'], + 'selectTags' => ['tag', 'value'], + 'selectMacros' => ['macro', 'value', 'description', 'type'] + ]; + $host_get_params = CompatHelper::buildHostGetParams($host_get_params); + + $source_host = API::Host()->get($host_get_params); + + $source_host = $source_host ? CompatHelper::normalizeHost($source_host[0]) : []; + + // Check for duplicate host names within the batch. + $batch_names = []; + $duplicate_names = []; + foreach ($host_data as $item) { + $host_name = trim($item['host'] ?? ''); + if ($host_name === '') { + continue; + } + if (in_array($host_name, $batch_names)) { + $duplicate_names[] = $host_name; + } + $batch_names[] = $host_name; + } + + // Check for existing hosts in Zabbix with the same names. + $existing_hosts = []; + if (!empty($batch_names)) { + $existing = API::Host()->get([ + 'output' => ['hostid', 'host', 'name'], + 'filter' => ['host' => array_unique($batch_names)] + ]); + foreach ($existing as $eh) { + $existing_hosts[$eh['host']] = $eh; + } + } + + // Also check templates with the same names (host name must be unique across hosts and templates). + $existing_templates = []; + if (!empty($batch_names)) { + $existing_tpl = API::Template()->get([ + 'output' => ['templateid', 'host'], + 'filter' => ['host' => array_unique($batch_names)] + ]); + foreach ($existing_tpl as $et) { + $existing_templates[$et['host']] = $et; + } + } + + // Build conflict info for each host entry. + $conflicts = []; + foreach ($host_data as $idx => $item) { + $host_name = trim($item['host'] ?? ''); + $ip = trim($item['ip'] ?? ''); + $issues = []; + + if ($host_name === '') { + $issues[] = LangHelper::t('err.host_required'); + } + if ($ip === '') { + $issues[] = LangHelper::t('err.ip_required'); + } + if (in_array($host_name, $duplicate_names)) { + $issues[] = LangHelper::t('err.batch_duplicate'); + } + if (isset($existing_hosts[$host_name])) { + $issues[] = LangHelper::t('err.host_exists_in_zabbix'); + } + if (isset($existing_templates[$host_name])) { + $issues[] = LangHelper::t('err.template_exists'); + } + + $conflicts[$idx] = $issues; + } + + // Prepare host groups and templates lookup maps for name resolution. + $all_groups = API::HostGroup()->get([ + 'output' => ['groupid', 'name'], + 'preservekeys' => false + ]); + $group_map = []; + foreach ($all_groups as $g) { + $group_map[$g['name']] = $g['groupid']; + } + + $all_templates = API::Template()->get([ + 'output' => ['templateid', 'host', 'name'], + 'preservekeys' => false + ]); + $template_map = []; + foreach ($all_templates as $t) { + $template_map[$t['host']] = $t['templateid']; + $template_map[$t['name']] = $t['templateid']; + } + + // Build field status for each row: detect which groups/templates exist vs need creation. + $field_status = []; + foreach ($host_data as $idx => $item) { + $fstatus = [ + 'groups' => [], + 'templates' => [], + 'groups_inherited' => true, + 'templates_inherited' => true, + 'tags_inherited' => true, + 'macros_inherited' => true + ]; + + // Groups + $groups_input = trim($item['groups'] ?? ''); + if ($groups_input !== '') { + $fstatus['groups_inherited'] = false; + $group_names = array_filter(array_map('trim', explode(';', $groups_input))); + foreach ($group_names as $gn) { + $fstatus['groups'][] = [ + 'name' => $gn, + 'status' => isset($group_map[$gn]) ? 'exists' : 'new' + ]; + } + } + + // Templates + $templates_input = trim($item['templates'] ?? ''); + if ($templates_input !== '') { + $fstatus['templates_inherited'] = false; + $template_names = array_filter(array_map('trim', explode(';', $templates_input))); + foreach ($template_names as $tn) { + // Try both host and name lookup + $found = isset($template_map[$tn]); + $fstatus['templates'][] = [ + 'name' => $tn, + 'status' => $found ? 'exists' : 'not_found' + ]; + } + } + + // Tags + $fstatus['tags_inherited'] = (trim($item['tags'] ?? '') === ''); + + // Macros + $fstatus['macros_inherited'] = (trim($item['macros'] ?? '') === ''); + + $field_status[$idx] = $fstatus; + } + + $data = [ + 'source_host' => $source_host, + 'host_data' => $host_data, + 'conflicts' => $conflicts, + 'duplicate_names' => array_unique($duplicate_names), + 'existing_hosts' => $existing_hosts, + 'existing_templates' => $existing_templates, + 'group_map' => $group_map, + 'template_map' => $template_map, + 'field_status' => $field_status, + 'total_count' => count($host_data), + 'conflict_count' => count(array_filter($conflicts, function ($c) { + return !empty($c); + })) + ]; + + $response = new CControllerResponseData($data); + $this->setResponse($response); + } +} diff --git a/zabbix_clonehosts/actions/ClonehostsSource.php b/zabbix_clonehosts/actions/ClonehostsSource.php new file mode 100644 index 0000000..7c143dd --- /dev/null +++ b/zabbix_clonehosts/actions/ClonehostsSource.php @@ -0,0 +1,93 @@ +disableCsrfValidation(); + } + if (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + } + + protected function checkInput(): bool { + $ret = $this->validateInput([ + 'source_hostid' => 'required|string' + ]); + + if (!$ret) { + $this->setResponse(new CControllerResponseFatal()); + } + + return $ret; + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + $source_hostid = $this->getInput('source_hostid'); + + // Use CompatHelper for version-correct host group select parameter. + $host_get_params = [ + 'output' => ['hostid', 'host', 'name', 'status', 'description', + 'inventory_mode', 'tls_connect', 'tls_accept' + ], + 'hostids' => [$source_hostid], + 'selectInterfaces' => ['type', 'main', 'useip', 'ip', 'dns', 'port'], + 'selectParentTemplates' => ['templateid', 'host', 'name'], + 'selectTags' => ['tag', 'value'], + 'selectMacros' => ['macro', 'value', 'description', 'type'] + ]; + $host_get_params = CompatHelper::buildHostGetParams($host_get_params); + + $source_host = API::Host()->get($host_get_params); + + $source_host = $source_host ? CompatHelper::normalizeHost($source_host[0]) : null; + + $data = [ + 'success' => !empty($source_host), + 'source_host' => $source_host, + 'error' => empty($source_host) ? LangHelper::t('err.source_not_found') : '' + ]; + + $response = new CControllerResponseData($data); + $this->setResponse($response); + } +} diff --git a/zabbix_clonehosts/assets/css/clonehosts.css b/zabbix_clonehosts/assets/css/clonehosts.css new file mode 100644 index 0000000..89b328a --- /dev/null +++ b/zabbix_clonehosts/assets/css/clonehosts.css @@ -0,0 +1,1064 @@ +/* +** Zabbix +** Copyright (C) 2001-2024 Zabbix SIA +** +** Styles for Host Batch Clone module. +*/ + +/* ===== Page Layout ===== */ +.clonehosts-wrapper { + padding: 0; +} + +.clonehosts-page { + max-width: 100%; +} + +.clonehosts-section { + margin-bottom: 20px; + padding: 15px 20px; + background: #ffffff; + border: 1px solid #dfe4e7; + border-radius: 4px; +} + +.clonehosts-section h3 { + margin: 0 0 10px 0; + font-size: 15px; + font-weight: 600; + color: #1f1f1f; + padding-bottom: 8px; + border-bottom: 1px solid #ebebeb; +} + +.clonehosts-section .description { + margin: 0 0 15px 0; + font-size: 12px; + color: #768390; + line-height: 1.5; +} + +/* ===== Filter Fields ===== */ +.filter-field-wrapper { + margin-bottom: 12px; +} + +.filter-field-label { + display: block; + margin-bottom: 4px; + font-size: 12px; + font-weight: 600; + color: #4d5966; +} + +/* ===== Multiselect Control ===== */ +.multiselect-wrapper { + position: relative; +} + +.multiselect-selected { + display: flex; + flex-wrap: wrap; + gap: 4px; + min-height: 0; + margin-bottom: 4px; +} + +.multiselect-selected:empty { + margin-bottom: 0; +} + +.ms-tag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: #e8f5ff; + border: 1px solid #b3d9ff; + border-radius: 3px; + font-size: 12px; + color: #1f2c33; + line-height: 1.4; +} + +.ms-tag-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 2px; + font-size: 9px; + font-weight: 700; + color: #fff; + background: #0275b8; +} + +.ms-tag-name { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-tag-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + margin-left: 2px; + font-size: 14px; + line-height: 1; + color: #768d99; + cursor: pointer; + border-radius: 2px; +} + +.ms-tag-remove:hover { + background: #d4e8f7; + color: #dc3838; +} + +.multiselect-input-row { + display: flex; + align-items: center; + gap: 6px; +} + +.multiselect-input { + flex: 1; + min-width: 150px; + padding: 5px 8px; + border: 1px solid #afb7be; + border-radius: 3px; + font-size: 12px; + background: #fff; + height: 28px; + color: #1f2c33; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.multiselect-input:focus { + border-color: #4298e8; + outline: none; + box-shadow: 0 0 0 2px rgba(66, 152, 232, 0.15); +} + +.multiselect-input::placeholder { + color: #b0b8bf; + font-style: italic; +} + +.multiselect-button { + padding: 5px 12px; + border: 1px solid #768390; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + background: #e9edf0; + color: #333333; + cursor: pointer; + white-space: nowrap; + height: 28px; + transition: all 0.2s; +} + +.multiselect-button:hover { + background: #d4dae0; + border-color: #4d5966; + color: #1f1f1f; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); +} + +.multiselect-button:active { + background: #c5cdd4; +} + +/* ===== Inline Dropdown ===== */ +.multiselect-dropdown { + position: absolute; + top: 100%; + left: 0; + z-index: 100; + min-width: 100%; + max-width: 500px; + max-height: 280px; + overflow-y: auto; + margin-top: 2px; + background: #fff; + border: 1px solid #afb7be; + border-radius: 3px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.ms-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + cursor: pointer; + font-size: 12px; + color: #1f2c33; +} + +.ms-dropdown-item:hover, +.ms-dropdown-item.active { + background: #e8f5ff; + color: #0275b8; +} + +.ms-item-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} + +.ms-item-icon.host-icon { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23768d99' d='M13.5 2h-11A1.5 1.5 0 0 0 1 3.5v9A1.5 1.5 0 0 0 2.5 14h11a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 13.5 2zM8 11.5a.5.5 0 0 1-.5-.5V7.707L6.354 8.854a.5.5 0 1 1-.708-.708l2-2a.5.5 0 0 1 .708 0l2 2a.5.5 0 1 1-.708.708L8.5 7.707V11a.5.5 0 0 1-.5.5z'/%3E%3C/svg%3E"); + opacity: 0.7; +} + +.ms-item-icon.group-icon { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='%23768d99' d='M8 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3.5 9a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zm9 0a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z'/%3E%3C/svg%3E"); + opacity: 0.7; +} + +.ms-dropdown-item:hover .ms-item-icon, +.ms-dropdown-item.active .ms-item-icon { + opacity: 1; +} + +.ms-item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ms-dropdown-no-match { + padding: 15px; + text-align: center; + color: #768d99; + font-size: 12px; + font-style: italic; +} + +/* ===== Popup Dialog ===== */ +.ms-popup-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.3); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +.ms-popup-dialog { + background: #fff; + border: 1px solid #dfe4e7; + border-radius: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + width: 520px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; +} + +.ms-popup-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 15px; + border-bottom: 1px solid #dfe4e7; + background: #f4f6f8; + border-radius: 4px 4px 0 0; +} + +.ms-popup-title { + font-size: 14px; + font-weight: 600; + color: #1f1f1f; +} + +.ms-popup-close { + font-size: 20px; + line-height: 1; + color: #768d99; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 3px; +} + +.ms-popup-close:hover { + background: #dfe4e7; + color: #dc3838; +} + +.ms-popup-search { + padding: 10px 15px; + border-bottom: 1px solid #ebeef0; +} + +.ms-popup-search input { + width: 100%; + padding: 6px 10px; + border: 1px solid #afb7be; + border-radius: 3px; + font-size: 13px; + background: #fff; + color: #1f2c33; +} + +.ms-popup-search input:focus { + border-color: #4298e8; + outline: none; + box-shadow: 0 0 0 2px rgba(66, 152, 232, 0.15); +} + +.ms-popup-list-wrapper { + flex: 1; + overflow-y: auto; + max-height: 400px; +} + +.ms-popup-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.ms-popup-table thead { + position: sticky; + top: 0; + z-index: 5; +} + +.ms-popup-table thead th { + background: #f4f6f8; + border-bottom: 1px solid #dfe4e7; + padding: 6px 8px; + text-align: left; + font-weight: 600; + color: #4d5966; +} + +.ms-popup-check-col { + width: 32px; + text-align: center; +} + +.ms-popup-name-col { + padding: 4px 8px; +} + +.ms-popup-table tbody tr { + cursor: pointer; + border-bottom: 1px solid #ebeef0; +} + +.ms-popup-table tbody tr:hover { + background: #e8f5ff; +} + +.ms-popup-table tbody td.ms-popup-check-col { + text-align: center; + padding: 4px 8px; +} + +.ms-popup-table tbody td.ms-popup-name-col { + padding: 4px 8px; + color: #1f2c33; +} + +.ms-popup-no-match { + padding: 20px; + text-align: center; + color: #768d99; + font-style: italic; +} + +.ms-popup-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 15px; + border-top: 1px solid #dfe4e7; + background: #f4f6f8; + border-radius: 0 0 4px 4px; +} + +.ms-popup-count { + font-size: 12px; + color: #768d99; +} + +.ms-popup-actions { + display: flex; + gap: 8px; +} + +.ms-popup-actions .btn-grey { + padding: 5px 14px; + border: 1px solid #768390; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + background: #e9edf0; + color: #333333; + cursor: pointer; + height: 28px; +} + +.ms-popup-actions .btn-grey:hover { + background: #d4dae0; +} + +.ms-popup-apply { + padding: 5px 14px; + border: 1px solid #4298e8; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + background: #4298e8; + color: #fff; + cursor: pointer; + height: 28px; +} + +.ms-popup-apply:hover { + background: #3b87d1; + border-color: #3b87d1; +} + +/* ===== Source Host Selection ===== */ +.source-host-select-wrapper { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 15px; +} + +.source-host-select { + min-width: 350px; + width: 100%; + max-width: 600px; + padding: 4px; + border: 1px solid #afb7be; + border-radius: 3px; + font-size: 12px; + background: #fff; + height: 200px; + overflow-y: auto; +} + +.source-host-select option { + padding: 3px 6px; +} + +.source-host-select option:checked { + background: #4298e8; + color: #fff; +} + +.source-host-select:focus { + border-color: #4298e8; + outline: none; + box-shadow: 0 0 0 2px rgba(66, 152, 232, 0.15); +} + +#load-source-btn { + align-self: center; +} + +.source-host-info { + margin-top: 15px; + padding: 12px 15px; + background: #f4f6f8; + border: 1px solid #dfe4e7; + border-radius: 4px; +} + +.source-host-info h4 { + margin: 0 0 10px 0; + font-size: 14px; + color: #1f1f1f; +} + +.source-info-table { + width: 100%; + border-collapse: collapse; +} + +.source-info-table th { + text-align: right; + white-space: nowrap; + padding: 4px 12px 4px 0; + font-weight: 600; + color: #4d5966; + font-size: 12px; + width: 130px; + vertical-align: top; +} + +.source-info-table td { + padding: 4px 0; + font-size: 12px; + color: #1f1f1f; +} + +.inherited-note { + margin: 10px 0 0 0; + font-size: 11px; + color: #768390; + font-style: italic; +} + +/* ===== Tabs ===== */ +.tabs-nav { + display: flex; + gap: 0; + margin-bottom: 0; + border-bottom: 2px solid #dfe4e7; +} + +.tab-btn { + padding: 8px 20px; + border: 1px solid #dfe4e7; + border-bottom: none; + background: #f4f6f8; + color: #4d5966; + font-size: 13px; + cursor: pointer; + border-radius: 4px 4px 0 0; + transition: all 0.2s; +} + +.tab-btn:hover { + background: #e9edf0; +} + +.tab-btn.active { + background: #fff; + color: #1f1f1f; + border-bottom: 2px solid #fff; + margin-bottom: -2px; + font-weight: 600; +} + +.tab-content { + display: none; + padding: 15px 0; +} + +.tab-content.active { + display: block; +} + +/* ===== File Upload ===== */ +.file-upload-area { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.file-upload-area input[type="file"] { + padding: 4px; + font-size: 13px; +} + +.csv-parse-result { + margin-top: 15px; +} + +.csv-parse-summary { + display: flex; + gap: 20px; + padding: 8px 12px; + background: #f4f6f8; + border-radius: 3px; + margin-bottom: 10px; + font-size: 13px; +} + +.csv-preview-table { + font-size: 12px; +} + +.more-rows { + text-align: center; + color: #768390; + font-style: italic; + padding: 8px; +} + +/* ===== Online Table ===== */ +.table-toolbar { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.row-count { + margin-left: auto; + font-size: 12px; + color: #768390; +} + +.table-wrapper { + overflow-x: auto; + max-height: 500px; + overflow-y: auto; + border: 1px solid #dfe4e7; + border-radius: 3px; +} + +.host-data-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.host-data-table thead { + position: sticky; + top: 0; + z-index: 10; +} + +.host-data-table th { + background: #f4f6f8; + border-bottom: 1px solid #dfe4e7; + padding: 6px 8px; + text-align: left; + font-weight: 600; + color: #4d5966; + white-space: nowrap; +} + +.host-data-table td { + border-bottom: 1px solid #ebebeb; + padding: 4px 6px; +} + +.host-data-table input[type="text"] { + width: 100%; + padding: 3px 6px; + border: 1px solid #c5cdd4; + border-radius: 2px; + font-size: 12px; + background: #fff; +} + +.host-data-table input[type="text"]:focus { + border-color: #4298e8; + outline: none; + box-shadow: 0 0 0 1px rgba(66, 152, 232, 0.2); +} + +.host-data-table input[type="text"]::placeholder { + color: #b0b8bf; + font-style: italic; +} + +.col-checkbox { + width: 32px; + text-align: center; +} + +.col-host, .col-ip { + min-width: 140px; +} + +.col-visible-name, .col-port { + min-width: 100px; +} + +.col-groups, .col-templates, .col-tags, .col-macros { + min-width: 150px; +} + +.col-description { + min-width: 120px; +} + +/* Column widths for preview table */ +.host-data-table .col-status { + width: 50px; + text-align: center; +} + +/* ===== Preview Table ===== */ +.row-ok { + background: #fff; +} + +.row-conflict { + background: #fff5f5; +} + +.inherited { + color: #9aa3ac; + font-style: italic; + font-size: 11px; +} + +.icon-ok { + color: #4caf50; + font-weight: bold; +} + +.icon-conflict { + color: #f44336; + font-weight: bold; + font-size: 14px; +} + +.preview-summary-bar { + display: flex; + gap: 20px; + padding: 10px 15px; + background: #f4f6f8; + border-radius: 3px; + font-size: 13px; +} + +.summary-item { + font-weight: 500; +} + +.summary-total { + color: #1f1f1f; +} + +.summary-ok { + color: #4caf50; +} + +.summary-conflict { + color: #f44336; +} + +/* ===== Action Bar ===== */ +.action-bar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; +} + +.preview-hint { + font-size: 12px; + color: #768390; +} + +.conflict-warning { + color: #f44336; + font-size: 12px; + font-weight: 500; +} + +/* ===== Buttons ===== */ +.btn { + display: inline-block; + padding: 6px 16px; + font-size: 13px; + border-radius: 3px; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s; + text-decoration: none; + line-height: 1.4; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #4298e8; + color: #fff; + border-color: #4298e8; +} + +.btn-primary:hover:not(:disabled) { + background: #3b87d1; + border-color: #3b87d1; +} + +.btn-secondary { + background: #e9edf0; + color: #333333; + border: 1px solid #768390; + font-weight: 600; +} + +.btn-secondary:hover:not(:disabled) { + background: #d4dae0; + border-color: #4d5966; + color: #1f1f1f; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); +} + +.btn-secondary:active:not(:disabled) { + background: #c5cdd4; + border-color: #4d5966; +} + +/* Back button - extra prominence for navigation */ +.btn-back { + background: #4298e8; + color: #ffffff; + border: 1px solid #4298e8; + font-weight: 600; + padding: 6px 18px; +} + +.btn-back:hover:not(:disabled) { + background: #0275b8; + color: #ffffff; + border-color: #0275b8; + box-shadow: 0 2px 4px rgba(2, 117, 184, 0.3); +} + +.btn-back:active:not(:disabled) { + background: #02609b; + color: #ffffff; + border-color: #02609b; +} + +.btn-link { + background: transparent; + color: #4298e8; + border: none; + text-decoration: underline; + padding: 6px 8px; +} + +.btn-link:hover:not(:disabled) { + color: #3b87d1; +} + +/* ===== Progress Bar ===== */ +.progress-bar-wrapper { + display: flex; + align-items: center; + gap: 15px; + margin-bottom: 20px; +} + +.progress-bar-container { + flex: 1; + height: 24px; + background: #e9edf0; + border-radius: 12px; + overflow: hidden; + border: 1px solid #dfe4e7; +} + +.progress-bar { + height: 100%; + background: linear-gradient(90deg, #4298e8, #54a9f5); + border-radius: 12px; + transition: width 0.3s ease; + min-width: 0; +} + +.progress-text { + font-size: 13px; + font-weight: 600; + color: #4d5966; + white-space: nowrap; + min-width: 80px; +} + +/* ===== Results ===== */ +.results-summary { + display: flex; + gap: 25px; + padding: 12px 15px; + background: #f4f6f8; + border-radius: 3px; + margin-bottom: 15px; + align-items: center; +} + +.result-item { + font-size: 14px; + font-weight: 600; +} + +.result-success { + color: #4caf50; +} + +.result-failed { + color: #f44336; +} + +.result-total { + color: #1f1f1f; +} + +.results-table .result-success td { + color: #1f1f1f; +} + +.results-table .result-failed td { + color: #f44336; +} + +.error-msg { + color: #f44336; + font-size: 12px; +} + +.encoding-select { + padding: 4px 8px; + border: 1px solid #afb7be; + border-radius: 3px; + font-size: 12px; + background: #fff; + height: 28px; + color: #1f2c33; +} + +.encoding-hint { + margin: 5px 0 0 0; + font-size: 11px; + color: #768d99; + font-style: italic; +} + +/* ===== Field Status Badges ===== */ +.col-field-status { + min-width: 150px; + max-width: 250px; +} + +.field-badge { + display: inline-block; + padding: 2px 7px; + margin: 1px 2px; + border-radius: 3px; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; + font-weight: 500; +} + +.field-badge-exists { + background: #e8f5e9; + color: #2e7d32; + border: 1px solid #a5d6a7; +} + +.field-badge-new { + background: #e3f2fd; + color: #1565c0; + border: 1px solid #90caf9; +} + +.field-badge-not_found { + background: #fff3e0; + color: #e65100; + border: 1px solid #ffcc80; +} + +/* Legend for field status */ +.field-legend { + display: flex; + gap: 15px; + padding: 8px 0; + margin-bottom: 8px; + font-size: 11px; + align-items: center; + flex-wrap: wrap; +} + +.field-legend-item { + display: flex; + align-items: center; + gap: 5px; + color: #768d99; +} + +.field-legend-swatch { + display: inline-block; + width: 20px; + height: 12px; + border-radius: 2px; +} + +.field-legend-swatch.exists { background: #e8f5e9; border: 1px solid #a5d6a7; } +.field-legend-swatch.new { background: #e3f2fd; border: 1px solid #90caf9; } +.field-legend-swatch.not_found { background: #fff3e0; border: 1px solid #ffcc80; } +.field-legend-swatch.inherited { background: #f4f6f8; border: 1px solid #dfe4e7; } + +/* ===== Responsive ===== */ +@media (max-width: 1200px) { + .host-data-table { + font-size: 11px; + } + + .host-data-table input[type="text"] { + font-size: 11px; + } + + .source-host-select { + min-width: 250px; + } + + .multiselect-dropdown { + max-width: 100%; + } +} + +@media (max-width: 768px) { + .multiselect-input-row { + flex-direction: column; + align-items: stretch; + } + + .multiselect-button { + width: 100%; + } + + .multiselect-dropdown { + position: static; + width: 100%; + max-width: 100%; + margin-top: 2px; + } + + .source-host-select-wrapper { + flex-direction: column; + } + + .source-host-select { + max-width: 100%; + height: 150px; + } + + .ms-popup-dialog { + width: 95vw; + max-height: 90vh; + } +} diff --git a/zabbix_clonehosts/assets/js/clonehosts.js.php b/zabbix_clonehosts/assets/js/clonehosts.js.php new file mode 100644 index 0000000..1004187 --- /dev/null +++ b/zabbix_clonehosts/assets/js/clonehosts.js.php @@ -0,0 +1,1037 @@ + + +(function($) { + 'use strict'; + + $(function() { + var data = window.clonehostsData || {}; + var sourceHostid = ''; + var parsedRows = []; + + // ===== i18n ===== + var L = data.lang || {}; + + function t(key) { + return L[key] || key; + } + + // ===== CSV Column Definitions ===== + var CSV_COLUMNS = [ + 'host', // Host Name (*) + 'visible_name', // Visible Name + 'ip', // Interface IP (*) + 'port', // Port + 'groups', // Host Groups (semicolon-separated) + 'templates', // Templates (semicolon-separated) + 'tags', // Tags (tag=value;tag=value) + 'macros', // Macros ({$MACRO}=value;{$MACRO}=value) + 'description' // Description + ]; + + function getCSVHeaders() { + return [ + t('csv.header.host'), + t('csv.header.visible_name'), + t('csv.header.ip'), + t('csv.header.port'), + t('csv.header.groups'), + t('csv.header.templates'), + t('csv.header.tags'), + t('csv.header.macros'), + t('csv.header.description') + ]; + } + + // ===== Tab Switching ===== + $('.tab-btn').on('click', function() { + var tab = $(this).data('tab'); + $('.tab-btn').removeClass('active'); + $(this).addClass('active'); + $('.tab-content').removeClass('active').hide(); + $('#tab-' + tab).addClass('active').show(); + updatePreviewButton(); + }); + + // ===== Dual Multiselect: Host Group + Host (with linkage) ===== + var allHosts = data.hosts || []; + var allGroups = data.hostGroups || []; + var selectedGroups = []; // [{id, name}] + var selectedHosts = []; // [{id, name, host}] + var msInputTimer = null; + var msDropdownVisible = false; + var msActiveItemIndex = -1; + var msCurrentFilteredItems = []; + var msActiveContext = null; // 'group' or 'host' + + // Popup dialog state + var popupContext = null; // 'group' or 'host' + var popupSelected = {}; // id -> {id, name} (temp selections in popup) + var popupFilteredItems = []; // current filtered list in popup + + function getHostLabel(host) { + var label = host.name || ''; + if (host.host && host.host !== host.name) { + label += ' (' + host.host + ')'; + } + return label; + } + + function fuzzyMatch(text, keyword) { + if (!keyword) return true; + text = (text || '').toLowerCase(); + keyword = keyword.toLowerCase().trim(); + return text.indexOf(keyword) >= 0; + } + + // ===== Get hosts filtered by selected groups ===== + function getHostsFilteredByGroups() { + if (selectedGroups.length === 0) { + return allHosts.slice(); + } + var groupIds = selectedGroups.map(function(g) { return g.id; }); + return allHosts.filter(function(h) { + if (!h.groupids || h.groupids.length === 0) return false; + for (var i = 0; i < h.groupids.length; i++) { + if (groupIds.indexOf(h.groupids[i]) >= 0) return true; + } + return false; + }); + } + + // ===== Render Selected Tags ===== + function renderGroupTags() { + var $container = $('#group-ms-selected'); + $container.empty(); + $.each(selectedGroups, function(i, g) { + var html = '' + + '' + + '' + escapeHtml(g.name) + '' + + '×' + + ''; + $container.append(html); + }); + } + + function renderHostTags() { + var $container = $('#host-ms-selected'); + $container.empty(); + $.each(selectedHosts, function(i, h) { + var label = h.name; + if (h.host && h.host !== h.name) { + label = h.name + ' (' + h.host + ')'; + } + var html = '' + + '' + + '' + escapeHtml(label) + '' + + '×' + + ''; + $container.append(html); + }); + } + + // ===== Add/Remove Tags ===== + function addGroup(id, name) { + id = String(id); + for (var i = 0; i < selectedGroups.length; i++) { + if (String(selectedGroups[i].id) === id) return; + } + selectedGroups.push({ id: id, name: name }); + renderGroupTags(); + pruneHostsByGroups(); + renderHostTags(); + applyFilters(); + } + + function addHost(id, name, host) { + id = String(id); + selectedHosts = [{ id: id, name: name, host: host || '' }]; + renderHostTags(); + applyFilters(); + } + + function pruneHostsByGroups() { + if (selectedGroups.length === 0) return; + var filteredHosts = getHostsFilteredByGroups(); + var validIds = filteredHosts.map(function(h) { return h.hostid; }); + selectedHosts = selectedHosts.filter(function(h) { + return validIds.indexOf(h.id) >= 0; + }); + } + + function removeGroup(id) { + id = String(id); + selectedGroups = selectedGroups.filter(function(g) { return String(g.id) !== id; }); + renderGroupTags(); + pruneHostsByGroups(); + renderHostTags(); + applyFilters(); + } + + function removeHost(id) { + id = String(id); + selectedHosts = selectedHosts.filter(function(h) { return String(h.id) !== id; }); + renderHostTags(); + applyFilters(); + } + + // ===== Inline Dropdown for Group Search ===== + function renderGroupDropdown(keyword) { + var $dd = $('#group-ms-dropdown'); + keyword = (keyword || '').toLowerCase().trim(); + + var matched = allGroups.filter(function(g) { + if (!fuzzyMatch(g.name, keyword)) return false; + for (var i = 0; i < selectedGroups.length; i++) { + if (selectedGroups[i].id === g.groupid) return false; + } + return true; + }); + + msCurrentFilteredItems = []; + $dd.empty(); + + if (matched.length === 0) { + $dd.append('
| ' + t('preview.interfaces') + ' | ';
+ if (host.interfaces && host.interfaces.length > 0) {
+ var ifaces = [];
+ $.each(host.interfaces, function(i, iface) {
+ var typeName = typeNames[iface.type] || (t('iface.Type') + iface.type);
+ var addr = iface.useip == 1 ? iface.ip : iface.dns;
+ ifaces.push(typeName + ': ' + addr + ':' + iface.port);
+ });
+ html += ifaces.join(' '); + } else { + html += t('preview.none'); + } + html += ' |
|---|---|
| ' + t('col.host_groups') + ' | '; + if (host.hostgroups && host.hostgroups.length > 0) { + html += $.map(host.hostgroups, function(g) { return escapeHtml(g.name); }).join(', '); + } else { + html += t('preview.none'); + } + html += ' |
| ' + t('col.templates') + ' | '; + if (host.parenttemplates && host.parenttemplates.length > 0) { + html += $.map(host.parenttemplates, function(tp) { return escapeHtml(tp.name); }).join(', '); + } else { + html += t('preview.none'); + } + html += ' |
| ' + t('col.tags') + ' | '; + if (host.tags && host.tags.length > 0) { + html += $.map(host.tags, function(tp) { return escapeHtml(tp.tag + '=' + tp.value); }).join(', '); + } else { + html += t('preview.none'); + } + html += ' |
| ' + t('col.macros') + ' | '; + if (host.macros && host.macros.length > 0) { + html += $.map(host.macros, function(m) { return escapeHtml(m.macro + '=' + m.value); }).join(', '); + } else { + html += t('preview.none'); + } + html += ' |
| ' + t('col.description') + ' | ' + escapeHtml(host.description || '') + ' |
' + t('step1.inherited_note') + '
'; + + $('#source-host-info').show().html(html); + } + + // ===== CSV File Upload ===== + $('#csv-file-input').on('change', function() { + var file = this.files[0]; + $('#parse-csv-btn').prop('disabled', !file); + }); + + $('#parse-csv-btn').on('click', function() { + var file = $('#csv-file-input')[0].files[0]; + if (!file) return; + + var encoding = $('#csv-encoding-select').val() || 'UTF-8'; + var reader = new FileReader(); + reader.onload = function(e) { + var text = e.target.result; + // Strip UTF-8 BOM if present + if (text.charCodeAt(0) === 0xFEFF) { + text = text.substring(1); + } + parsedRows = parseCSV(text); + + var resultDiv = $('#csv-parse-result'); + if (parsedRows.length === 0) { + resultDiv.show().html('| ' + h + ' | '; + }); + html += '||||||||
|---|---|---|---|---|---|---|---|---|
| ' + escapeHtml(row.host || '') + ' | '; + html += '' + escapeHtml(row.visible_name || '') + ' | '; + html += '' + escapeHtml(row.ip || '') + ' | '; + html += '' + escapeHtml(row.port || '') + ' | '; + html += '' + escapeHtml(row.groups || '') + ' | '; + html += '' + escapeHtml(row.templates || '') + ' | '; + html += '' + escapeHtml(row.tags || '') + ' | '; + html += '' + escapeHtml(row.macros || '') + ' | '; + html += '' + escapeHtml(row.description || '') + ' | '; + html += '
| ' + t('csv.more_rows') + (parsedRows.length - 10) + t('csv.more_rows_suffix') + ' | ||||||||
~@yp)(EOi8iZ(6QWB#c)N*;;qppB@GXl35o$D#R%0 zUyGk>OmldL`QnnjpIWHMGSQoR6sAf?F)`H)VWlup{_v*J#o=H+;}?)Qq(Q2HCiD_# z2rLP^KtIGR81>oxgrSy)^6BIjGhW`RgjoE6&g)7YqxNl|%_8=fkYP<2j2`@nj8TK` z(K58#e4xrh11M4}{;^E&vw;=u-CwuGxVK^CO9h^b$sYS7S>H@5IT~#PEpKm~mJq2vK3BPXUX+stD z3}u%pBf&d^&yeVO1WNW}XJv?69QGeY2Go3xjP~v`mEzEt sZp)3)4?86m&J22Mm8(jtqBbxvqj2@(;US3pJwCJ?atzRzbmwM{09$T64^F7 znv7eQH540(8M9Z5oQDh4xiQZgJrRq;*1ERq{+@1hrVAPH+7N;WT7$cuSI}_TKU?Ba z>mADLpkpKNij9_Q>6*053Hee62EYdHedLdPJvYC5eV9tG=MneZV%%d(F1TGBn4K+2 z-A+;ZvNo85 G<8n_=G@0Qy>+n)&k`d81&C#v_J+ZvEDuqk)>yvDO3n4~_KK`&lE- z!MbwPqnN#cRGHA!y-#dP`dl8Lk}O _lqK&CSx-ZTZAJ+DBRrj&9Y?#KW;&6fN&Du#{b(u8?O= ze5f3$xhbug^MoO?nLj%{_^EcR5zBDco1u1<=3`yrW{zx9TT2U!O{%f&U_Qx*7yNlX z>7r092+?mn{#S`a4-=Wf(t~vNWrSc=Zhosu!fo$Sg;Z+~sIqW|(J;PLsJpjCCz`D5 z+iiQ6EFbZ|$mefGCf0*W- HdF@@h631N{Hf5{iGyLo#MH`)$3c<@n)CX12bj`( z-IVPHm~>q$j4bcRkGE q(EYE4<2iebdNJ z+sTXFNL%Z1E173)RLs*l1>Q+@!52@~aGv9<)Y-pPnSGGLu=pH;-&;5uUmp`X>J(ml zl*}zs-vv_8Ihg{Zm4gb6PL&3@MiJFRv+bUeP~lw!vDD4YLMDS0^oFlEwSs>P-RkXB z{;)9KJ44@Z_~w|^h}Z*`M=%Om{GkGSV#ql+bo6bZKdQM%N7xe~-(6w$Rk<7A_Y!3` zHS~1sc3y6lp&>=0(Q{_%o#BoC)0*OWJ20!fb>IuHsZS{;ZO}+gjpalDI3PC{a6Bxz zdcTv92Mw%g^VUgRqD~7&RQnv Rx>AW~Cf;MAaIP7N6*-o80q zi-7z?5PMy14fNeRI{d4v=j~eu(9u-IE~icsX8CM(gq>cri;!Ttt#uL&B@C~$=>1H% zI%M-TDK5?M-pm$zj241CmSBlG_}qRO#S*(e3Zcu@pZ~IJ&-k|@LRt5byEupXR&Rua ztuN)|>6$(lkN65Bs-}pg7$kN`ywf*|-<^SPVEf)K(*KxsQ`gBpe%eC!oH?uMyS0ck ziT4IhY%V>3(JvXmb}6Noxuo0P8 LuCt~R&ASh;|*?t@-m z{sVm70^;ovHrjs#3XX|nX>{3$3;+=}zD_ mfhEvbfFB@}7h5rPR@w4JwMiTdX%2 z+!B9m!PF-Z1^Thw>~voP)2Nw4=0P>__UB3ipjL ;Ld<;O#KJpl;TxvMP_+kd{) zveOLIZF&R+equ)a*AkiJk je0S3Sx7yKZqU}8LG2M;!%Wh2E3%~0yqu2O92vc}y zS;NVF3BBi`^B#9$Y|lx`xf$VO3eP=VrnxBG+-8VeAvYr>B_o9xHTf?MLr9zJ;ijX> zGn%c9q~~6_9zyPeoo$NG2jk7UCdJ4We`W@dlM`pG-TI|2b~~DEM6^vxz?iG|ay#5D zE>TmtG&n|&^*L~@nsd!zAM5?tO^1$t>|PD1Ti87g@1sfInx3X|cxy+!l5Q1QyBt~- zd+10<4McE&f5>tuA47U(gKC&Zy-DKb0^71@q$ pZ{XIP@lwO~7&TDSAVq( Menw#`f`d<|M#nhsU>RB-r<@8bdg{31qyDOO& zk;;qL55@2qlC#S{06*!xtPp3yzi=5GO-2p;hQ6K#R!pN~wRa2?pC+~yQ1r8Y9 UCmY1x&uX^5U5Fq zf9ZtkZ~{v#cj{Z5u5>Z-_MN>q0PsBTuFl&R2ARxr0%Oazw~t;z%F1^h=d|Ri$2Haf z$se_Qhn2LA;^l@a<(!GfK1luXA|I>^tBrw;ucl79b=5^jq6>5@DxyF6>NPrs%;i(V z#o|E1q4^>H(o;M02LC|5-L;r;m*Im)5Wiw^Ez$=!@MGy=GO;tF_4r~F?zTs`bl+{O zz6x{lx`~6?3G$A)g`OzfIPBQ?zV7jmj$lUrg_vNAOP $~N%y3Tr}p{`aGAR)PKce2eE@zbf?7_Y@% zN1D+v0+5UDZEdcZQtjo|o%Nk0Q1BN1VBh`BDllo^ReAQ_G7A7e-tA@ZRewiR@u #tlody2{|p#1Rztb8bArM^c*?4a(lz+QN? z)lKexY|J&D_HZTL+*d%>$0T<(T|&X-_Pnw#ER@326hg)Xy+x^4W+U6nZ_54klN?7R zZb$JyETNx1HZbyNxoK8_wY028qvh-LW=nms9_lmc*3XRQ_?oa-YrCz#oC v@$vfADm#Z20L-Y~t+cQ4?@++`K^o5;E&S8z+ApyE>?f zy@sI91>L7Db`tewF+8wXMJ9S5x>|=K$Dc9R$;^mWEX})Kkf=pHDy$L2qQpa7m-_h% z3om$*7c+bex?tx-y$?OY^3(n)T0S_Kvd8?-bV9Ts@UmbPpw+wvw|<~{5ywM7ac8@N zx@c||qqkKi{bv&~%U+#S5nE}6V+iplf&M(T&pX;vu@wc3b)=U*j2fQ2Z3S|}1~==u z`Qu^qhUmnG*(T7~_jdpglgKESQB#^0!f-p8LHy1VzGGZ`<^Ew#C|ZWCjpS=``AyF{ zA9V91(7Tk=41U&UI;=Jg36Dqm*spq6bMdQxM4ifIC@oinnKHnD6FL{mH21J>541~l zmlZJ#E@`}ZvRg|3b9=UtvA9eXzCqAtJ5SuxfV^rKPKIygb|C!l*VR@>w(y$tLjFHK zI%34>4Mf(ft$%2HLKRQ{$?oxHM)RG6P7{9nu-H>WQ>D|Ceku`HZwNY85fwta>$bx4 zUUpPbbNH~GdQ@5Yb|}hb_x56v)p`clv9(G2EZl7LIAMF|3M?cVk)_Q&+CTA{^@Jv; z@`B?NQ_=CCgy;4JT{q)=Bb0=;Oj{by1MM@P=A}>7%7;Uj+>9V1c^O;%wcckP>j!}h zm%vnT4EBm9ryxEyl>F3%JGYhkN&O3W7`Y*PUR6?yuw`pFF(c}Ntj_zpWaXRszlk4d zG0;+7koCwHb@P#owCmiks9sA+`9{y^pPHVY3laSOtz=kM+S)0Hl%@7%dO@ijS$xM@ zLxT;Wd=uRs-W|jC7mSN&OuPjT40gim-`6?8sa)~)M;CV~d-D@UMsEr$5B)$>&%r9O zv3iF5fufz4DbA4X2!cBHUs}a!)g2$pi+0A8`A_7m+`(5QmTyU6Cv{rjANs|MHF+v& zDJ`!B1%MN(Oo*obNn$`lVf{b2OQCljY%hx8=U+k*Y62R1f^X=6V8Hv35Wk$iT+iTL z_0qYP8U>?9EpdS8$A@5z{CEFh80K-WHad^&^{}yz#l%eRRq=@CH8i&?eIG sCFcJ|#*v_~Li;%NZby*AV zc)n!Y(}PHDEwt^1a&e5EzXEn z-JTY7K!q|3k^0^ZiGaXNT2FRib$?_xvWaS`8Fnye_@gQ5K4uIQc`DJ9*vG?<)Tk9x zD-2!_;n5y>8h#ax%E`(~L+n|T(7tinsTTa# hQt5HlPl7sTc3@@O*<)pL4>&gWJ->Uifvx2JOX^8C~nelctU?vXt zJZ?3*=n10!a98H;wQ{$S$cM4~&X|bQY#kk0Y(2|?s^T@0=o6hA7jYN6_v4RFbmre? z+I~Zs1LP_yiO*?GF3(%zChgM6i{6Ev9ql7hJMsk5*B({jktPk^Ys$-<2H~Y3Z;DLo zl**6%{~}1g6$uGi SOVloEE|I)f& zQs88HoBX?=q3QqZ`osUNWuyOJ9p5%MZ Qq@O<`MM-9)Fz_ zmiu?rlR|#vi+>I8|Bo(j;o@UrKhKsJ8l=o!WALcD!dn<`wOQGKX=|m-voszzF5*5H zl{8h<;@X@GuI%u n%#MonW+P{-J%@Z7)Tg|_m2Xa(@i?lG+l^z+KAkxompJFm!&f4K?ck9j74C-% z9=iX4%J-j5laiN#uo7+ljb01+*R!dknt16VsxDVQ3Kv(vxqN{5>X=vQ&cR!ax^_L< zRAaO;MzLjIJ#M`qkMq2jXkOB+sTlEaHBXWI`xXxS)|a-4qht(?GbpogU&TE2W$|kb zG_?QYyhEAYuC<&-Qb=1 Z~LKmOkT=sx5tKz`U~Esg5nRddMWr?AS;-=q4eb5zG~Wu*2r4*<4HPI{-at@-qO zqGNmFn!G#($X3pQcI8@8Y1*!5Z*USldwkT;!uJ9V``^()WTM`B(hR4>WcTv 7hm(?{GE(6pQTlAqhsvV>c9j;ZJB8iG}pcR3!l7gmej!0YP6U-Dk zLecj|DL$QR1&Z yjbQz6*FEh zxhvYLvgvu)kNIN&&KzLV4c@>bArQ5ch!|>?R$&dVsH4aOWF39M$PI^W3gOs70;A=~ zzeFtN-|rmqF7XoC7wWaD8QDzTmFN(#rj5`|Ssw2HSjMIxHpZx%%NmIEwn7EiHGSh8 zsoW=35!%;whoHBrakoj`m;k@ }Bi)N~hTsGLhS*i9v1aFop=IPq z4_!MoVsk|Vyy&cCY)>C4yLBRAA+K#S5pwr-#={JYV*cpQIiwqu H lju6sz=dGyv%ZcB@r*<^3 zXpPfWvnwC#c{})DQV?R05uOK`Dx?C8uHc!46lCxN2yYH21hH|CyfoZ=1+WCp6Oezz zY|HTJ^qtPlk?2Y~qb6-sPZ{Nup8Pe#?On1hQf*h`vRD6 KH(^yuJyKKyrtYfBILf3;~94} kMb9fj0{;3(>I>E~H9AQ!wWAT(c|Ae!r$Y2>h%DPktQhIZ7kK& nGquZN_iGtWX|E(-pN-+!#F^+7Y*iII9Y+kH+aToLx63wdf+?PaSbP3^ zC%I2o>0LA!nPKwF1k;aR?0=WCDalqDHse`X%)K{DZ?1rR6bef#(^B{7>mA*?sL?d% z8`i;fr6UJ7alk$XkdDSd?ACH~aDo_F2sIpIZO4QuQFm?s_bx!kiC fe^TnX9C zUxS4Ea&4&kr1tOalNTB=4N>`})HV}$vcbZ0KOWA-EX_K3 BgpZ-R{N=aYv5arPOy-$-?N9a5oT5`9ye+! zVEH32`9Waa#ML;wqeM)Yi9~1iOl9# dvgfyrKBChP_Wbh z%eNr}4M#A+==P>wpNMyw4Mtt{NL6FiN=tk425b4-z%q=}lx#Hv4@=)^zW{jXm%V@r z`l6mlr1q)VjZ25UJ>*Gpqm7W3vql658^4D;Wkd(exj16VuPloXCI*>ZJJyvIGS%Bq z9DK#;f-ChIy=f}+qk5F)H`$PV+!)tLZEtt^j`h619Fp&~%juc;P(iWybFTS!1U4VF zzb_PbdA2uOH|h!*Wb;Y>U^aPgeNiH4e|>FGu?i}Z8@v;^;jf$4>*m@^#ZVG$j(q}8 z_b7MsWd0cs@g?$zznvO?&x#JV#Y1FnT20^-tLuFC3bR^^Pn{twRWh}>q9JG!g9}Mj z0IBfIGm^Su$auLgjmSGgg#X;1CX`CxvfzA;+JW5aj^thm_u^NRmbiC)-9}E=ot*8x z1k6jO3=3ztoD|>2w1zR^TH^hcmc$U6H m)NdMfT@v_GHjcvsvX$tR3 zXSEkwTN%t@%3?Ri;L+r>U7 Kgc-UszSu*)}9Fi zlAnv%rkd`;mJt-dS!#*oMPGKOIDPQu` gUIo! zz=2e#;x)b8Y!q87mCH^;1z($0Cpon&iTt)dT=fkmBOqjiSFY_ZbhfUNTIUGwFtMe6 zm~BF;8UE_NTskW~f?(WC&*mDzWzPYFcbv|kn?hME)&{ ;G|r*i|T& z?}n;k`SE;NM6Yx~9LYbPB=<@KO<#f&_iIMTSYZ2#=zn~)rb$35({N&K)4uhS8q>pq z<}mS8dI}2;g}NTY7OJbtEi~Q>k q@=-nl+pN*@god@;7gULpip4swYVFFP+4o>Zl%!AW#SByyUnwN zzn!slFG%I`cnT1jcOjuiA2eMb{?2v(@eqk8_q|B)^;nP#xjeRYgJpO!6DH3r45r!f zwXNTkg`cB3 S<{D)E6Y8xmYgm#p<|)1`exMy7=&g&NCx|Q2jV1dm&>LY0 zTBl)0b*y*YK>;${eu=Ar4Ffz@SRl&~73>3h4^Xr5wq)j@x`A=mtQXSQpfi@Cxq=9D zWZcDNdE(U(49G3oMFX)$ojbzQz-v|GXHpSe5)30XG=Z3{Bo6L~tz3S)mh-$Bb4+FX zCU;I(AhJmGw8j34z)jKCm0Bo$%nQ_zVB)D`d&7f192Nyb5c_b1mFD&7<2}A}b*b(A zEx5uuhYEIGmmXt{gfn?x$Q6oQ)Wr>9K)X3yFiyLpFXCesa4KxV1&lc>?NpvQEJ9Fd zv+1K+5fZZ__hCVC=gL4Lvfp;+!M$|vSe(I5QnROX6i1y9FOB4wZUSp&! v&&2ik zMR>WZW{-E^Bgt*C93^?ZQu1xRPdMjcIk~bQT6tzZo*7%V#chB{;X3Vj8J`THd|0?V z*&1%!C|pWz0`YQJKLTP}PSd}RUm2dM@o3vW$L$Ryg*0BDUu1}vow*c9SAP6cPw`qm z-$M*lMRw-9B3QNqqiqU#YzQwxU+LUShRw4nH6X;W!*PwZkTHi0i#cJbv_9V=y(nl8 z4`Z7`Fl-6=ioKezntuqOdkUud5acA)vu54HPA4|v%V?V>LFo$nv;*;1Q1!~2IrhZA zDXNsSG1F?y)ZFRNF#D3tL%-y1TDEj1Jc|&UMKEHN)TR37MX6laROTF$Js~@hXN9%B zk9! NTM9^CO4n&VG;{F#^A&=E?P=^wY5jv z@~;d{CXg)qRIa#{xXjgsH{PqoJNgZ1?RNa|c|ijf$xTQtylAQEC7T)H+pakoIcUC3 zP^vu=x5|H^n nA##|JR}IK2aI2NI3twn;?eCv{|TESg5rY8@Y+T7 z+lD1!;QTP>IcQ>M8 (A~)@Z*tylo1ZkQA%o@D6{PJxt@8`?2WW*bxURQs z7`|ZN6EwE5ub;#GCb?aFxEbzkgESh?AgsO(dx5Ez`ou%mov60;?sS6*Q?rTAKJE;l zXX9Q1rOfDftW%=@y*S%4Y+k@B*Wi;Jmq)k&T^i7~&skyr#jo~__E~S__deDC%|%{E z6hH-?M=5uVGOQcyhovm_>=b&mweQZ3YAZQsF!|m(+T|COR9)+Bqe`e9;$cO$4h+~c z0NQ4KO3!%>9*ZuL@qOv9F !VhTtiTCl3PgO^ eCHw^l3B!4DU zs$A$C9kosMm3j#GFXNYQo-4)8paDpQ_o_AN=#^1Cua!UvBL~zvlzK1bczE>T+3#_a zi7Ao&94V{ntfMrNI%6Ah`q17G5r}1CwL_*YA b@2r~(Mn+rS{m`M?D-ufxo{Ni< zy7Gg4`uJJ>0v~u2Q_J!v$M}ikuKqdCR7uz3zmz6|p8N
c_t5jEsHxpeACr%!L^1byhz=k2ITt+FeA7AdcH$XJu}t zluOGveVyH4+27B@GlOqlgXXW`L39{d#OF-h`U!Tt48isbxeQMkNr9KHY_keF ZY)Im!u1L z_tI0i`;XP@9rY>jSwa;`OO86S)!LbMUP0n@h@w*M03$99TdIl?ILC-vLd`c91w+vv z6@NCp>{|a_`awaO*ze;@&-%Z$C}?g6;q9xVz3J4a =L1aBu>yS!~7R%be_?+hxb$%?e(A-}HhL@mlQmIvGWSWfVo%Mns^LMB;40`1}+G zqocQ(SbIGE>i_rUM-S}4JhpbjN}Ko>B6|tf=dM0%1`p=pnGGIgXWev{p6|b}T3UFv z& b`t zS>3hF$w4-v!pN1p*2fl+sN{eZerOaPpfH4Snwtew6h^*fOC_(gDcz1~p6I{&tTXoo zlmb>Iryw{nXc_j@vp#XVkoaCy2PYmS^*Ky&fdv882@!65#<~Q8t=jnHmP9Jg9_(`N zZ_&)P`^q9edWbD+7Nb?48qDF2Wmy=jPMFcC yg_n7ks^b@&eZ?{)<7N;d*I; zAw%g9#)?VPtl*XvV}KSM@uRP%aMFP9R!6@(Lp5GQj(_(bnU0D-WLU_9|0Ft5+-+o` zje@AG%C(XO)>T*F?#OVTyKY*0Tb4zZ%m%P6@Rf&dB!06xh3(ygE(@P8GQTMY%+;cI zWg%L_r&}f3l$>A)pj`|(qWE%3QgXl35jOL{Ys`5e$a1G4Hx)!Ugd6#LRygG4>is9L zVp&{ljEfVimHITFVyc~xmtu~Ry4s45iEmDjG|T*VX$vbWIk&$T8{n++Anj ys}{m85K0pUU#iD>w2gQi?1g__Bp} zUq3tT|Dmh18iMg=4k#(^m_y>fEmoKx*LGxXYq#_1q!a~mg{>gdC+p!zv|TL$8jOu> zq%4I8S08U@v)F%LscWu%LJnZvf~Sc7>RDY)tdw)v-g#B5$AaAM= Ig{955qIn9+$?C#bt<8SuU(1NQlo=1@b*hoyv63Uoi2zyB32Sykvxs1 zUyqB=w{`qLM-j3hOljoatq?@?`s6bsm(w&EnYZ8ghu1GZ%+TKHF@C7bW#vb|5jK9l zi=Tp7GO4g{Uk6fd)^Yjbx7b{V@TM;+- wgvG7lf$VGTg*j|Mh zv+@dxEMs5@M%K!$lux`OjeuwBJ=*cPwD;ZbS=t%|x6ij`*H06WAyD|}j+d?9+Ymcn z8Js6EJ|7<&7wt`z!xCW$C5CuJZ9qQT;MmYarzE>o#pPaV5u>tUv!%mX*qIw=QkwsE z{*W^k-dt#BV&Dt-%UsW!jM}526nr2(MKIymh-H+VS9x_G8^m9t(m7@8gnu`TwEgQw z{(G4$w-wb{?lsqaX2<&XKSjF&O%u*Q8(u=S=|5SHA%=hqSUU1^E;Fyxf%zDhAN}oa z_X+t6O1^e3yM1qY3~MtZf;8mq?_g`kHQ(QcxUIA!-lAWboaB)m1H{()Sn#)rk*o9- zv=;nceU%DB@|WZZY>9wFSnM4$t{s-PwUvENTd`x|tbzCbCSQI)H3(Y|(ZcnPOnx^Y z?hn6`uOK*n$7c_WX)&_OcKXl=Yl&hJW(4=QDdShWo8!i-Ff0USZ8wP`rL0TD58wL{ z%wPl}%4nGqr4JaU)|8pe=^bmg4Vnc?r5%H9QS`=Jlt}oKQb2M|$*ns-DyE(KjM;d0 z#thYpF@KOfeNJ2dU^Qbk#?7t6ieDnqwa*3cK42JurAjW?9{w25YRC)1N?YPjLYEo_ z-S%Q#epS53hz>iGwDtROOfX{0#iCdDH-bUTVZzRHoIAR03VvQ}fOqhtfP}8BXGkWO zY=2)pZ71HDEkSdSCSdlnqN7s8yS0M36koCrD+SG^wdsohHv zk%h7?cnSN8aSe3nTu(-8l@$7;uv?+1<`1^t+r&v O+ |6xYHB1&2JQKLWEJ%;Fh|2)1uCBgtK^Ws&E;n>Ub#7 Oq0dSDl(O1_Qa0&U!Gv4--s7;lz^|Osi zvg8V4hWtrxJT{C><-Qvt?`S!9cBJfU(ySv4Esx7ed2@gAC%Ua=dX~VD<7qyx$eZnH z{efbwjxiL=9P9CMZmQ>H?;S=}I?^l?^RN45|HcMKe}vL@Jm6ld7i7ofgD!!1C?KpC z`+gK4dFs=GH@yUsbob@itm7Lc`fS%f _9`?LisnPS4V>aqqoDK zKcB}a?%Q9^$OQ(}WwyxQQ1)ReS%sy&x>#N1)zZ^2dmVOC_bsc!P(b2pDdA@VWBiIa z=YoSfSGP(?l-K;%ed0@>^A~CcXv1KXIr(5LM>5?+7kdk7vKb|H7B>=`MFIH0c4 *_)lh}Sz~ z&wN{>&BD0>2%jxvG9arH?*5x!w7<$48^S(ItZ$}H&~}V?Bd#|M>&ke6{I*OZ (qOz6JAlw_nk6|QOPC=!5t%WKxwG3UTpw?=JzU~+hMq$OJEaY?nWYV) HN%kA!uw_$Q0O%GBwb@==zHS0l1r5)!LTl%iITI`p}&D%Q*_OI?|2mHAe(q*x6 zS0fF#LLSaq``@&UeyzmHN0phM40%hQ)sp6;z;hrn8TvL$+Hf_kRXJUilC*Lf{Wogp zpj8RT(A|?B%Z2@&wNA7)1b}MlS3458qbW#vBz7WWE)5*vg}03Dj^dfma_sDLUb*O2 zH7Ev+u;QBY&3(YmAQ2+p(Ih@Xf0gVAHx<_p -CMVhaRoWal z_c75X&{HRWFS`YryDMB(RJXXgN$~K;=wj?oP+He*K1LR0yLoB7ZEo`?3o2QIcQ{jI zjHOr=D`sz)wW;k|PEJF3RW9HuyrB2qrb!j+`Pp%6H{A&BiqpGQp@qjuL8^J5=w{9d z_G1WA1F9ScBs%7^girC2tjn8^zV7$f0M3Z(28zH~8N&zzXA;mfR{hcK9P5jFsg0h7 z#jX~HCsN&~U2K z{ci(Nck?=}RmFHedA9fP4O?UI;WA*dqY=L>MG-E~Fb|1jb&DKm2h;%i)pT4n_S__* zhSGJh@^ZhEl-1muP6wxpw@Ak?Y_SGeuyoYM_imuFHjnbJ)b~q*coOpY-6j9Vk)zfL z|AdAK@hm?+$dst-YuS11i6YSllW3=bLUWa2y{BcoCYqViN0{)4Ngvpd zPB7jAt~06ty^tj3cX90>Yv^42d#_xuK6ks){up7Bf4?SdN-;O^;D|E7Gy`U8z? zIsY^q{hY{H3bA5~4K5=g#j9@xU~b$u+;P46Xhwd-w_@s1+9_3pEur5Ox-XKQsYn>j zCOqYNlGwlqIK-727XB@rKy}l`NiM_9F~XA`-B+D@r@pPO_4_==W8eB~dgjNFD@p6r zl_80DIydU7@BHdF9nYG-uN9