diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..34552db --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,202 @@ +name: Release Zabbix Modules + +# 当任意模块的 manifest.json 中 version 字段升级并推送到主分支时, +# 自动打包所有模块和整体项目到一个 Release 中。 +# 版本号格式:模块数量.大版本.小版本(如 8.1.0) +# Release Assets 包含: +# - zabbix_modules-<版本号>.tar.gz(整体打包) +# - zabbix_<模块名>-<版本号>.tar.gz(每个模块独立打包) +# 也支持手动触发。 + +on: + push: + branches: + - main + - master + paths: + - 'zabbix_*/manifest.json' + workflow_dispatch: + +jobs: + detect-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed modules with version bump + id: detect + run: | + # Find all module directories + modules=() + for dir in zabbix_*/; do + [ -d "$dir" ] || continue + [ -f "${dir}manifest.json" ] || continue + modules+=("${dir%/}") + done + + echo "Found modules: ${modules[*]}" + + # Determine whether to release: + # - Manual trigger: always release + # - Push trigger: only when at least one module's version changed + should_release="false" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + should_release="true" + echo "Manual trigger: will release" + else + for mod in "${modules[@]}"; do + mf="${mod}/manifest.json" + current_ver=$(python3 -c "import json; print(json.load(open('$mf'))['version'])") + prev_ver=$(git show HEAD~1:"${mf}" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "") + if [ -z "$prev_ver" ] || [ "$current_ver" != "$prev_ver" ]; then + echo " $mod: version changed (${prev_ver:-new} -> ${current_ver}), will release" + should_release="true" + else + echo " $mod: version unchanged (${current_ver}), skip" + fi + done + fi + + echo "should_release=${should_release}" >> $GITHUB_OUTPUT + + - name: Package all modules and project into single Release + if: steps.detect.outputs.should_release == 'true' + run: | + # Count modules + MODULE_COUNT=$(ls -d zabbix_*/ 2>/dev/null | wc -l) + echo "Module count: ${MODULE_COUNT}" + + # Find the latest major version for this module count + # Version format: {module_count}.{major}.{minor} (e.g., 8.1.0) + LATEST_MAJOR=0 + for tag in $(git tag --list "v${MODULE_COUNT}.*"); do + major=$(echo "${tag}" | sed "s/v${MODULE_COUNT}\.\([0-9]*\)\.[0-9]*/\1/") + if [[ "${major}" =~ ^[0-9]+$ ]] && [ "${major}" -gt "${LATEST_MAJOR}" ] 2>/dev/null; then + LATEST_MAJOR="${major}" + fi + done + + MAJOR_VER=$((LATEST_MAJOR + 1)) + VERSION="${MODULE_COUNT}.${MAJOR_VER}.0" + echo "Release version: ${VERSION}" + + tag="v${VERSION}" + + # Check if tag already exists + if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Tag ${tag} already exists, skipping release" + exit 0 + fi + + mkdir -p assets + + # Package each module + echo "=== Packaging individual modules ===" + MODULE_LIST="" + for mod in zabbix_*/; do + mod="${mod%/}" + [ -f "${mod}/manifest.json" ] || continue + mod_ver=$(python3 -c "import json; print(json.load(open('${mod}/manifest.json'))['version'])") + echo "Packaging ${mod} v${mod_ver}..." + tar -czf "assets/${mod}-${mod_ver}.tar.gz" "${mod}" + ls -lh "assets/${mod}-${mod_ver}.tar.gz" + MODULE_LIST="${MODULE_LIST}- ${mod} - v${mod_ver}\n" + done + + # Package entire project (all modules together) + echo "=== Packaging entire project ===" + tar -czf "assets/zabbix_modules-${VERSION}.tar.gz" \ + --exclude=".git" \ + --exclude=".github" \ + --exclude="*.md" \ + --exclude="*.csv" \ + --exclude="images" \ + zabbix_*/ + + echo "Packaged zabbix_modules-${VERSION}.tar.gz:" + ls -lh "assets/zabbix_modules-${VERSION}.tar.gz" + + # Create release notes file + { + echo "## Zabbix Modules ${VERSION}" + echo "" + echo "### Included Modules" + echo "" + for mod in zabbix_*/; do + mod="${mod%/}" + if [ -f "${mod}/manifest.json" ]; then + mod_ver=$(python3 -c "import json; print(json.load(open('${mod}/manifest.json'))['version'])") + echo "- ${mod} - v${mod_ver}" + fi + done + echo "" + echo "### Assets" + echo "" + echo "- \`zabbix_modules-${VERSION}.tar.gz\` - 所有模块整体打包(All modules in one package)" + echo "- \`zabbix_-.tar.gz\` - 单个模块独立打包(Individual module packages)" + echo "" + echo "### Deployment" + echo "" + echo "#### 选项 A:部署所有模块(整体包)" + echo "" + echo "1. Download \`zabbix_modules-${VERSION}.tar.gz\` below" + echo "2. Upload to Zabbix server and extract:" + echo "" + echo '```bash' + echo "# Zabbix 6.0 / 7.0" + echo "tar -xzf zabbix_modules-${VERSION}.tar.gz -C /usr/share/zabbix/modules/" + echo "" + echo "# Zabbix 7.2+ / 7.4 / 8.0" + echo "tar -xzf zabbix_modules-${VERSION}.tar.gz -C /usr/share/zabbix/ui/modules/" + echo '```' + echo "" + echo "3. If using Zabbix 6.0, change manifest_version for all modules:" + echo '```bash' + echo "sed -i 's/\"manifest_version\": 2.0/\"manifest_version\": 1.0/' /usr/share/zabbix/modules/zabbix_*/manifest.json" + echo '```' + echo "" + echo "#### Option B: Deploy a single module" + echo "" + echo "1. Download the \`zabbix_-.tar.gz\` file for the module you want" + echo "2. Upload to Zabbix server and extract:" + echo "" + echo '```bash' + echo "# Zabbix 6.0 / 7.0" + echo "tar -xzf zabbix_-.tar.gz -C /usr/share/zabbix/modules/" + echo "" + echo "# Zabbix 7.2+ / 7.4 / 8.0" + echo "tar -xzf zabbix_-.tar.gz -C /usr/share/zabbix/ui/modules/" + echo '```' + echo "" + echo "3. If using Zabbix 6.0, change manifest_version:" + echo '```bash' + echo "sed -i 's/\"manifest_version\": 2.0/\"manifest_version\": 1.0/' /usr/share/zabbix/modules/zabbix_/manifest.json" + echo '```' + echo "" + echo "### Enable modules in Zabbix Web" + echo "" + echo "1. Open Zabbix Web UI → Administration → General → Modules" + echo "2. Click **Scan directory** to detect new modules" + echo "3. Enable the modules you need" + } > release_notes.md + + # Create single Release with all assets + echo "=== Creating release ${tag} with all assets ===" + gh release create "${tag}" \ + assets/*.tar.gz \ + --title "Zabbix Modules ${VERSION}" \ + --notes-file release_notes.md \ + --repo "${{ github.repository }}" + + echo "=== Released ${tag} ===" + echo "Assets in release:" + gh release view "${tag}" --json assets --jq '.assets[].name' \ + --repo "${{ github.repository }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index ed73294..e22fe05 100644 --- a/README.md +++ b/README.md @@ -167,48 +167,113 @@ **兼容性**: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、结果、错误信息) +- 支持中英文界面国际化 + +![1](zabbix_clonehosts/images/image.png) +![2](zabbix_clonehosts/images/image-1.png) +![3](zabbix_clonehosts/images/image-2.png) +![4](zabbix_clonehosts/images/image-3.png) + +**文档链接**:[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 + ## 安装说明 -### 安装模块 +### 方式一:下载 Releases 压缩包(适合生产部署,按需选择) -这是最简单快捷的安装方式,一次性部署所有模块: +Releases 页面提供两种压缩包,无需安装 git: -1. **Zabbix 6.0 / 7.0 部署方法** +- **整体打包压缩包**:`zabbix_modules-<版本号>.tar.gz`(包含所有模块,版本号格式:`模块数量.大版本.小版本`,如 `8.2.0`) +- **单模块压缩包**:`zabbix_<模块名>-<版本号>.tar.gz`(按需下载单个模块) + +#### 选项 A:下载所有模块整体压缩包 + +1. 前往 [Releases 页面](https://github.com/X-Mars/zabbix_modules/releases),下载 `zabbix_modules-<版本号>.tar.gz` 文件(如 `zabbix_modules-8.2.0.tar.gz`) +2. 上传到 Zabbix 服务器并解压到模块目录: ```bash -git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ +# Zabbix 6.0 / 7.0 +tar -xzf zabbix_modules-<版本号>.tar.gz -C /usr/share/zabbix/modules/ + +# Zabbix 7.2+ / 7.4 / 8.0 +tar -xzf zabbix_modules-<版本号>.tar.gz -C /usr/share/zabbix/ui/modules/ ``` -2. **Zabbix 7.4 / 8.0 部署方法** +3. **如果使用 Zabbix 6.0,修改所有模块的 manifest_version** ```bash -git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ +for mod in /usr/share/zabbix/modules/zabbix_*/; do + sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' ${mod}manifest.json +done ``` -3. **如果使用Zabbix 6.0,修改manifest_version** +如果使用 Zabbix 7.0+ 或 8.0+,则无需修改,保持默认值即可。 + +#### 选项 B:下载单个模块 + +1. 前往 [Releases 页面](https://github.com/X-Mars/zabbix_modules/releases),下载所需模块的 `zabbix_<模块名>-<版本号>.tar.gz` 文件 +2. 上传到 Zabbix 服务器并解压到模块目录: + +```bash +# Zabbix 6.0 / 7.0 +tar -xzf zabbix_<模块名>-<版本号>.tar.gz -C /usr/share/zabbix/modules/ + +# Zabbix 7.2+ / 7.4 / 8.0 +tar -xzf zabbix_<模块名>-<版本号>.tar.gz -C /usr/share/zabbix/ui/modules/ +``` + +3. **如果使用 Zabbix 6.0,修改 manifest_version** + +```bash +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' /usr/share/zabbix/modules/zabbix_<模块名>/manifest.json +``` + +如果使用 Zabbix 7.0+ 或 8.0+,则无需修改,保持默认值即可。 + +### 方式二:git clone 直接部署(适合开发/跟踪更新) + +一次性部署所有模块: + +1. **Zabbix 6.0 / 7.0 部署方法** ```bash -cd /usr/share/zabbix/modules/ -# 修改 zabbix_reports 模块 -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_reports/manifest.json +git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ +``` -# 修改 zabbix_cmdb 模块 -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_cmdb/manifest.json +2. **Zabbix 7.2+ / 7.4 / 8.0 部署方法** -# 修改 zabbix_graphtrees 模块 -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_graphtrees/manifest.json +```bash +git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ +``` -# 修改 zabbix_rack 模块 -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_rack/manifest.json +3. **如果使用Zabbix 6.0,修改manifest_version** -# 修改 zabbix_snmp 模块 -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_snmp/manifest.json +一键修改所有模块: -# 修改 zabbix_jumpserver 模块 -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_jumpserver/manifest.json +```bash +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' /usr/share/zabbix/modules/zabbix_*/manifest.json +``` -# 修改 zabbix_im 模块 -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_im/manifest.json +单独修改某个模块(如 zabbix_reports): + +```bash +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' /usr/share/zabbix/modules/zabbix_reports/manifest.json ``` 如果使用 Zabbix 7.0+ 或 8.0+,则无需修改,保持默认值即可。 @@ -236,6 +301,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..645798c 100644 --- a/README_en.md +++ b/README_en.md @@ -90,9 +90,72 @@ 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. + +![1](zabbix_clonehosts/images/image.png) +![2](zabbix_clonehosts/images/image-1.png) +![3](zabbix_clonehosts/images/image-2.png) +![4](zabbix_clonehosts/images/image-3.png) + +- **Docs**: [zabbix_clonehosts/README_en.md](./zabbix_clonehosts/README_en.md)([中文](./zabbix_clonehosts/README.md)) + ## Installation -### Deploy all modules (recommended) +### Option 1: Download Release packages (for production, choose what you need) + +The Releases page offers two types of packages — no git required: + +- **All-in-one package**: `zabbix_modules-.tar.gz` (includes all modules; version format: `module_count.major.minor`, e.g., `8.2.0`) +- **Single-module package**: `zabbix_-.tar.gz` (download only the modules you need) + +#### Option A: Download the all-in-one package + +1. Go to the [Releases page](https://github.com/X-Mars/zabbix_modules/releases) and download the `zabbix_modules-.tar.gz` file (e.g., `zabbix_modules-8.2.0.tar.gz`). +2. Upload to the Zabbix server and extract to the modules directory: + + ```bash + # Zabbix 6.0 / 7.0 + tar -xzf zabbix_modules-.tar.gz -C /usr/share/zabbix/modules/ + + # Zabbix 7.2+ / 7.4 / 8.0 + tar -xzf zabbix_modules-.tar.gz -C /usr/share/zabbix/ui/modules/ + ``` + +3. If you run Zabbix 6.0, change `manifest_version` for every module: + + ```bash + for mod in /usr/share/zabbix/modules/zabbix_*/; do + sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' ${mod}manifest.json + done + ``` + + For Zabbix 7.0+ / 8.0+, no change is required. + +#### Option B: Download a single module + +1. Go to the [Releases page](https://github.com/X-Mars/zabbix_modules/releases) and download the `zabbix_-.tar.gz` file for the module you want. +2. Upload to the Zabbix server and extract to the modules directory: + + ```bash + # Zabbix 6.0 / 7.0 + tar -xzf zabbix_-.tar.gz -C /usr/share/zabbix/modules/ + + # Zabbix 7.2+ / 7.4 / 8.0 + tar -xzf zabbix_-.tar.gz -C /usr/share/zabbix/ui/modules/ + ``` + +3. If you run Zabbix 6.0, change `manifest_version`: + + ```bash + sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' /usr/share/zabbix/modules/zabbix_/manifest.json + ``` + + For Zabbix 7.0+ / 8.0+, no change is required. + +### Option 2: Deploy all modules via git clone (for development / tracking updates) 1. For Zabbix 6.0 / 7.0: @@ -100,7 +163,7 @@ This repository contains a collection of independent Zabbix frontend modules tha git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ ``` -2. For Zabbix 7.4 / 8.0: +2. For Zabbix 7.2+ / 7.4 / 8.0: ```bash git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ @@ -108,15 +171,16 @@ git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modu 3. If you run Zabbix 6.0, change `manifest_version` for each module: +One-liner for all modules: + +```bash +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' /usr/share/zabbix/modules/zabbix_*/manifest.json +``` + +For a single module (e.g., zabbix_reports): + ```bash -cd /usr/share/zabbix/modules/ -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_reports/manifest.json -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_cmdb/manifest.json -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_graphtrees/manifest.json -sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_rack/manifest.json -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/' /usr/share/zabbix/modules/zabbix_reports/manifest.json ``` For Zabbix 7.0+ / 8.0+, no change is required. @@ -136,6 +200,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..3718a36 --- /dev/null +++ b/zabbix_clonehosts/LangHelper.php @@ -0,0 +1,698 @@ + [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.status_success' => [ + 'en_GB' => 'Success:', + 'zh_CN' => '成功:' + ], + 'import.status_failed' => [ + 'en_GB' => 'Failed:', + 'zh_CN' => '失败:' + ], + 'import.status_pending' => [ + 'en_GB' => 'Pending:', + '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..5fc1d63 --- /dev/null +++ b/zabbix_clonehosts/Module.php @@ -0,0 +1,59 @@ +get('menu.main') + ->findOrAdd($main_menu_name) + ->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..612d26c --- /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 文件导入和在线表格录入两种方式,并提供预览、冲突检测和实时导入进度反馈功能。 + +![1](images/image.png) +![2](images/image-1.png) +![3](images/image-2.png) +![4](images/image-3.png) + +## 功能特性 + +- **源主机克隆**:选择任意已有监控主机作为克隆模板,其全部配置(接口、群组、模板、标签、宏、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.2+ / 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..0c6e0ed --- /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. + +![1](images/image.png) +![2](images/image-1.png) +![3](images/image-2.png) +![4](images/image-3.png) + +## 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.2+ / 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..77f145c --- /dev/null +++ b/zabbix_clonehosts/actions/ClonehostsImport.php @@ -0,0 +1,358 @@ +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') + . ': ' . json_encode($created, JSON_UNESCAPED_UNICODE); + } + } 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..61236d3 --- /dev/null +++ b/zabbix_clonehosts/assets/css/clonehosts.css @@ -0,0 +1,1090 @@ +/* +** 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; +} + +.progress-stats { + display: flex; + gap: 20px; + padding: 10px 15px; + background: #f4f6f8; + border-radius: 3px; + margin-bottom: 15px; +} + +.progress-stat { + font-size: 13px; + font-weight: 600; +} + +.progress-success { + color: #4caf50; +} + +.progress-failed { + color: #f44336; +} + +.progress-pending { + color: #768390; +} + +/* ===== 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 = '' + + 'G' + + '' + 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 = '' + + 'H' + + '' + 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('search.no_match') + '
'); + } else { + $.each(matched, function(i, g) { + var $item = $('
') + .data('id', g.groupid) + .data('name', g.name); + $item.append(''); + $item.append('' + escapeHtml(g.name) + ''); + $dd.append($item); + msCurrentFilteredItems.push($item[0]); + }); + } + + $dd.show(); + msDropdownVisible = true; + msActiveItemIndex = -1; + } + + // ===== Inline Dropdown for Host Search ===== + function renderHostDropdown(keyword) { + var $dd = $('#host-ms-dropdown'); + keyword = (keyword || '').toLowerCase().trim(); + + var availableHosts = getHostsFilteredByGroups(); + + var matched = availableHosts.filter(function(h) { + var label = getHostLabel(h); + if (!fuzzyMatch(label, keyword) && !fuzzyMatch(h.host, keyword)) return false; + for (var i = 0; i < selectedHosts.length; i++) { + if (selectedHosts[i].id === h.hostid) return false; + } + return true; + }); + + msCurrentFilteredItems = []; + $dd.empty(); + + if (matched.length === 0) { + $dd.append('
' + t('search.no_match') + '
'); + } else { + $.each(matched, function(i, h) { + var label = getHostLabel(h); + var $item = $('
') + .data('id', h.hostid) + .data('name', h.name) + .data('host', h.host); + $item.append(''); + $item.append('' + escapeHtml(label) + ''); + $dd.append($item); + msCurrentFilteredItems.push($item[0]); + }); + } + + $dd.show(); + msDropdownVisible = true; + msActiveItemIndex = -1; + } + + function hideMsDropdown() { + $('.multiselect-dropdown').hide(); + msDropdownVisible = false; + msActiveItemIndex = -1; + } + + function setMsActiveItem(index) { + $(msCurrentFilteredItems).removeClass('active'); + if (index >= 0 && index < msCurrentFilteredItems.length) { + msActiveItemIndex = index; + $(msCurrentFilteredItems[msActiveItemIndex]).addClass('active'); + msCurrentFilteredItems[msActiveItemIndex].scrollIntoView({ block: 'nearest' }); + } else { + msActiveItemIndex = -1; + } + } + + // ===== Keyboard Navigation for Multiselect Inputs ===== + function bindMsKeyboardNavigation($input, context) { + $input.on('keydown', function(e) { + if (!msDropdownVisible || msActiveContext !== context) { + if (e.which === 40) { // down arrow opens dropdown + if (context === 'group') renderGroupDropdown($(this).val()); + else renderHostDropdown($(this).val()); + msActiveContext = context; + e.preventDefault(); + } + return; + } + + if (e.which === 40) { // down + e.preventDefault(); + var next = msActiveItemIndex + 1; + if (next >= msCurrentFilteredItems.length) next = 0; + setMsActiveItem(next); + } else if (e.which === 38) { // up + e.preventDefault(); + var prev = msActiveItemIndex - 1; + if (prev < 0) prev = msCurrentFilteredItems.length - 1; + setMsActiveItem(prev); + } else if (e.which === 13) { // enter + e.preventDefault(); + if (msActiveItemIndex >= 0 && msActiveItemIndex < msCurrentFilteredItems.length) { + var $item = $(msCurrentFilteredItems[msActiveItemIndex]); + if (context === 'group') { + addGroup($item.data('id'), $item.data('name')); + $('#group-ms-input').val(''); + } else { + addHost($item.data('id'), $item.data('name'), $item.data('host')); + $('#host-ms-input').val(''); + } + hideMsDropdown(); + } + } else if (e.which === 27) { // escape + e.preventDefault(); + hideMsDropdown(); + } + }); + } + + bindMsKeyboardNavigation($('#group-ms-input'), 'group'); + bindMsKeyboardNavigation($('#host-ms-input'), 'host'); + + // ===== Group Input Events ===== + $('#group-ms-input').on('focus', function() { + if (!msDropdownVisible || msActiveContext !== 'group') { + renderGroupDropdown($(this).val()); + msActiveContext = 'group'; + } + }); + + $('#group-ms-input').on('input', function() { + clearTimeout(msInputTimer); + var val = $(this).val(); + msInputTimer = setTimeout(function() { + renderGroupDropdown(val); + msActiveContext = 'group'; + }, 150); + }); + + // ===== Host Input Events ===== + $('#host-ms-input').on('focus', function() { + if (!msDropdownVisible || msActiveContext !== 'host') { + renderHostDropdown($(this).val()); + msActiveContext = 'host'; + } + }); + + $('#host-ms-input').on('input', function() { + clearTimeout(msInputTimer); + var val = $(this).val(); + msInputTimer = setTimeout(function() { + renderHostDropdown(val); + msActiveContext = 'host'; + }, 150); + }); + + // ===== Dropdown Item Click ===== + $(document).on('mousedown', '#group-ms-dropdown .ms-dropdown-item', function(e) { + e.preventDefault(); + var $item = $(this); + addGroup($item.data('id'), $item.data('name')); + $('#group-ms-input').val('').focus(); + hideMsDropdown(); + }); + + $(document).on('mousedown', '#host-ms-dropdown .ms-dropdown-item', function(e) { + e.preventDefault(); + var $item = $(this); + addHost($item.data('id'), $item.data('name'), $item.data('host')); + $('#host-ms-input').val('').focus(); + hideMsDropdown(); + }); + + // ===== Remove Tag ===== + $(document).on('click', '.ms-tag-remove', function(e) { + e.stopPropagation(); + var $tag = $(this).closest('.ms-tag'); + var id = $tag.data('id'); + var type = $tag.data('type'); + if (type === 'group') removeGroup(id); + else removeHost(id); + }); + + // ===== Click Outside to Close Dropdown ===== + $(document).on('click', function(e) { + var $target = $(e.target); + if (!$target.closest('.multiselect-wrapper').length) { + hideMsDropdown(); + } + }); + + // ===== Popup Dialog for "Select" Button ===== + function openPopup(context) { + popupContext = context; + popupSelected = {}; + + if (context === 'group') { + $.each(selectedGroups, function(i, g) { popupSelected[String(g.id)] = { id: String(g.id), name: g.name }; }); + $('#ms-popup-title').text(t('multiselect.popup_title_group')); + } else { + $.each(selectedHosts, function(i, h) { popupSelected[String(h.id)] = { id: String(h.id), name: h.name, host: h.host || '' }; }); + $('#ms-popup-title').text(t('multiselect.popup_title_host')); + } + + $('#ms-popup-search-input').val(''); + $('#ms-popup-select-all').prop('checked', false); + renderPopupList(''); + $('#ms-popup-overlay').show(); + $('#ms-popup-search-input').focus(); + } + + function renderPopupList(keyword) { + keyword = (keyword || '').toLowerCase().trim(); + var $tbody = $('#ms-popup-tbody'); + $tbody.empty(); + popupFilteredItems = []; + + var items; + if (popupContext === 'group') { + items = allGroups.map(function(g) { return { id: g.groupid, name: g.name, host: '' }; }); + } else { + items = getHostsFilteredByGroups().map(function(h) { + return { id: h.hostid, name: h.name, host: h.host || '' }; + }); + } + + var matched = items.filter(function(item) { + var label = item.name; + if (item.host && item.host !== item.name) label += ' (' + item.host + ')'; + return fuzzyMatch(label, keyword) || fuzzyMatch(item.host, keyword); + }); + + if (matched.length === 0) { + $tbody.append('' + t('search.no_match') + ''); + updatePopupCount(); + return; + } + + $.each(matched, function(i, item) { + var isChecked = popupSelected[String(item.id)] !== undefined; + var label = item.name; + if (item.host && item.host !== item.name) label += ' (' + item.host + ')'; + var $tr = $('') + .data('id', item.id) + .data('name', item.name) + .data('host', item.host); + $tr.append(''); + $tr.append('' + escapeHtml(label) + ''); + $tbody.append($tr); + popupFilteredItems.push(item); + }); + + var allChecked = matched.length > 0 && matched.every(function(item) { return popupSelected[String(item.id)] !== undefined; }); + $('#ms-popup-select-all').prop('checked', allChecked); + updatePopupCount(); + } + + function updatePopupCount() { + var count = Object.keys(popupSelected).length; + $('#ms-popup-count').text(t('multiselect.popup_count').replace('{count}', count)); + } + + // Popup "Select" button click + $('#group-ms-btn').on('click', function() { openPopup('group'); }); + $('#host-ms-btn').on('click', function() { openPopup('host'); }); + + // Popup close + $('#ms-popup-close').on('click', closePopup); + $('#ms-popup-cancel').on('click', closePopup); + $('#ms-popup-overlay').on('click', function(e) { + if (e.target === this) closePopup(); + }); + + function closePopup() { + $('#ms-popup-overlay').hide(); + popupContext = null; + popupSelected = {}; + } + + // Popup search + $('#ms-popup-search-input').on('input', function() { + renderPopupList($(this).val()); + }); + + // Popup checkbox toggle (row click) + $(document).on('click', '#ms-popup-tbody tr', function(e) { + if ($(e.target).is('input[type="checkbox"]')) return; + var $cb = $(this).find('.ms-popup-checkbox'); + if (popupContext === 'host') { + $('.ms-popup-checkbox').prop('checked', false); + $cb.prop('checked', true); + popupSelected = {}; + var $tr = $(this); + var id = String($tr.data('id')); + popupSelected[id] = { id: id, name: $tr.data('name'), host: $tr.data('host') }; + updatePopupCount(); + $('#ms-popup-select-all').prop('checked', false); + applyPopupSelection(); + closePopup(); + } else { + $cb.prop('checked', !$cb.prop('checked')); + $cb.trigger('change'); + applyPopupSelection(); + closePopup(); + } + }); + + $(document).on('change', '.ms-popup-checkbox', function() { + var $tr = $(this).closest('tr'); + var id = String($tr.data('id')); + var name = $tr.data('name'); + var host = $tr.data('host'); + if ($(this).prop('checked')) { + popupSelected[id] = { id: id, name: name, host: host }; + } else { + delete popupSelected[id]; + } + updatePopupCount(); + var allChecked = popupFilteredItems.length > 0 && popupFilteredItems.every(function(item) { return popupSelected[String(item.id)] !== undefined; }); + $('#ms-popup-select-all').prop('checked', allChecked); + }); + + // Popup select-all + $('#ms-popup-select-all').on('change', function() { + var checked = $(this).prop('checked'); + $.each(popupFilteredItems, function(i, item) { + var sid = String(item.id); + if (checked) { + popupSelected[sid] = { id: sid, name: item.name, host: item.host }; + } else { + delete popupSelected[sid]; + } + }); + $('.ms-popup-checkbox').prop('checked', checked); + updatePopupCount(); + }); + + function applyPopupSelection() { + if (popupContext === 'group') { + selectedGroups = Object.values(popupSelected).map(function(g) { + return { id: String(g.id), name: g.name }; + }).sort(function(a, b) { return a.name.localeCompare(b.name); }); + renderGroupTags(); + pruneHostsByGroups(); + renderHostTags(); + } else { + selectedHosts = Object.values(popupSelected).map(function(h) { + return { id: String(h.id), name: h.name, host: h.host || '' }; + }).sort(function(a, b) { return a.name.localeCompare(b.name); }); + renderHostTags(); + } + applyFilters(); + } + + $('#ms-popup-apply').on('click', function() { + applyPopupSelection(); + closePopup(); + }); + + // ===== Filter Source Host List ===== + function applyFilters() { + if (selectedHosts.length > 0) { + var firstHost = selectedHosts[0]; + sourceHostid = firstHost.id; + $('#hidden-source-hostid').val(sourceHostid); + loadSourceHostInfo(sourceHostid); + } else { + sourceHostid = ''; + $('#hidden-source-hostid').val(''); + $('#source-host-info').hide().empty(); + } + updatePreviewButton(); + } + + // ===== Source Host Info Loading ===== + function loadSourceHostInfo(hostid) { + if (!hostid) return; + + $('#source-host-info').show().html('
' + t('step1.loading') + '
'); + + $.ajax({ + url: 'zabbix.php?action=clonehosts.source', + method: 'POST', + data: { + source_hostid: hostid + }, + dataType: 'json', + success: function(resp) { + if (resp.success && resp.source_host) { + displaySourceHostInfo(resp.source_host); + } else { + $('#source-host-info').show().html( + '
' + (resp.error || t('step1.load_failed')) + '
' + ); + } + }, + error: function() { + $('#source-host-info').show().html( + '
' + t('step1.load_failed_network') + '
' + ); + } + }); + } + + function displaySourceHostInfo(host) { + var typeNames = {1: t('iface.Agent'), 2: t('iface.SNMP'), 3: t('iface.IPMI'), 4: t('iface.JMX')}; + var html = '

' + t('step1.source_host_label') + escapeHtml(host.name) + '

'; + html += ''; + + // Interfaces + html += ''; + + // Host Groups + html += ''; + + // Templates + html += ''; + + // Tags + html += ''; + + // Macros + html += ''; + + // Description + html += ''; + + html += '
' + 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 || '') + '
'; + html += '

' + 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('
' + t('csv.no_data') + '
'); + return; + } + + // Validate rows + var validCount = 0; + var invalidCount = 0; + $.each(parsedRows, function(i, row) { + if (row.host && row.ip) { + validCount++; + } else { + invalidCount++; + row._invalid = true; + } + }); + + var html = '
'; + html += '' + t('csv.parsed_rows') + parsedRows.length + ''; + html += '' + t('csv.valid_rows') + validCount + ''; + if (invalidCount > 0) { + html += '' + t('csv.invalid_rows') + invalidCount + ''; + } + html += '
'; + + // Show parsed data preview (first 10 rows) + var csvHeaders = getCSVHeaders(); + html += '
'; + $.each(csvHeaders, function(i, h) { + html += ''; + }); + html += ''; + var previewCount = Math.min(parsedRows.length, 10); + for (var i = 0; i < previewCount; i++) { + var row = parsedRows[i]; + var cls = row._invalid ? 'row-conflict' : 'row-ok'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + } + if (parsedRows.length > 10) { + html += ''; + } + html += '
' + h + '
' + escapeHtml(row.host || '') + '' + escapeHtml(row.visible_name || '') + '' + escapeHtml(row.ip || '') + '' + escapeHtml(row.port || '') + '' + escapeHtml(row.groups || '') + '' + escapeHtml(row.templates || '') + '' + escapeHtml(row.tags || '') + '' + escapeHtml(row.macros || '') + '' + escapeHtml(row.description || '') + '
' + t('csv.more_rows') + (parsedRows.length - 10) + t('csv.more_rows_suffix') + '
'; + + // Also populate the online table with parsed data + populateTableFromRows(parsedRows); + + resultDiv.show().html(html); + updatePreviewButton(); + }; + reader.readAsText(file, encoding); + }); + + // ===== CSV Parsing ===== + function parseCSV(text) { + var rows = []; + var lines = text.split(/\r\n|\r|\n/); + var startIdx = 0; + + // Detect and skip header row + if (lines.length > 0) { + var firstLine = lines[0].toLowerCase().replace(/\s/g, ''); + if (firstLine.indexOf('hostname') >= 0 || firstLine.indexOf('host_name') >= 0 || + firstLine.indexOf('host') >= 0 || firstLine.indexOf('主机名称') >= 0) { + startIdx = 1; + } + } + + for (var i = startIdx; i < lines.length; i++) { + var line = lines[i].trim(); + if (line === '') continue; + + var fields = parseCSVLine(line); + if (fields.length === 0) continue; + + // Pad fields to match column count + while (fields.length < CSV_COLUMNS.length) { + fields.push(''); + } + + var row = {}; + for (var j = 0; j < CSV_COLUMNS.length; j++) { + row[CSV_COLUMNS[j]] = (fields[j] || '').trim(); + } + + // Skip completely empty rows + var hasData = false; + for (var key in row) { + if (row[key] !== '') { hasData = true; break; } + } + if (hasData) { + rows.push(row); + } + } + + return rows; + } + + function parseCSVLine(line) { + var fields = []; + var current = ''; + var inQuotes = false; + + for (var i = 0; i < line.length; i++) { + var char = line[i]; + + if (char === '"') { + if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { + // Escaped quote + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + fields.push(current); + current = ''; + } else { + current += char; + } + } + + fields.push(current); + return fields; + } + + // ===== CSV Template Download ===== + $('#download-template-btn').on('click', function() { + var csvHeaders = getCSVHeaders(); + var csv = csvHeaders.join(',') + '\n'; + // Add example rows + csv += '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\n'; + csv += '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\n'; + + var blob = new Blob(['\ufeff' + csv], {type: 'text/csv;charset=utf-8;'}); + var link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'clonehosts_template.csv'; + link.click(); + }); + + // ===== Online Table Management ===== + $('#add-row-btn').on('click', function() { + addTableRow(); + }); + + $('#remove-row-btn').on('click', function() { + $('#host-data-tbody input.row-checkbox:checked').each(function() { + $(this).closest('tr').remove(); + }); + updateRowCount(); + updatePreviewButton(); + }); + + $('#clear-table-btn').on('click', function() { + if (confirm(t('table.clear_confirm'))) { + $('#host-data-tbody').empty(); + addTableRow(); + updateRowCount(); + updatePreviewButton(); + } + }); + + $('#select-all-rows').on('change', function() { + var checked = $(this).prop('checked'); + $('#host-data-tbody input.row-checkbox').prop('checked', checked); + }); + + function addTableRow(rowData) { + rowData = rowData || {}; + var phRequired = t('table.placeholder.required'); + var phInherited = t('table.placeholder.inherited'); + var row = '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + $('#host-data-tbody').append(row); + updateRowCount(); + updatePreviewButton(); + } + + function populateTableFromRows(rows) { + $('#host-data-tbody').empty(); + $.each(rows, function(i, row) { + addTableRow(row); + }); + if (rows.length === 0) { + addTableRow(); + } + } + + function updateRowCount() { + $('#row-count').text($('#host-data-tbody tr').length); + } + + // ===== Preview Button Management ===== + function updatePreviewButton() { + var hasSource = !!sourceHostid; + var hasData = false; + + // Check if there's data in the table + $('#host-data-tbody tr').each(function() { + var host = $(this).find('.input-host').val(); + var ip = $(this).find('.input-ip').val(); + if (host && host.trim() && ip && ip.trim()) { + hasData = true; + return false; // break + } + }); + + // Also check parsed CSV rows + if (!hasData && parsedRows.length > 0) { + $.each(parsedRows, function(i, row) { + if (row.host && row.ip) { + hasData = true; + return false; // break + } + }); + } + + $('#preview-btn').prop('disabled', !(hasSource && hasData)); + + if (!hasSource) { + $('.preview-hint').text(t('preview.hint_no_source')); + } else if (!hasData) { + $('.preview-hint').text(t('preview.hint_no_data')); + } else { + $('.preview-hint').text(t('preview.hint_ready')); + } + } + + // Live validation + $(document).on('input', '#host-data-tbody input', function() { + updatePreviewButton(); + }); + + // ===== Form Submission to Preview ===== + $('#preview-btn').on('click', function() { + var hostData = collectHostData(); + if (hostData.length === 0) { + alert(t('preview.alert_no_data')); + return; + } + + $('#hidden-source-hostid').val(sourceHostid); + $('#hidden-host-data').val(JSON.stringify(hostData)); + $('#preview-form').submit(); + }); + + function collectHostData() { + var rows = []; + $('#host-data-tbody tr').each(function() { + var host = $(this).find('.input-host').val(); + var ip = $(this).find('.input-ip').val(); + + if (!host || !ip) return; // skip + host = host.trim(); + ip = ip.trim(); + if (!host || !ip) return; // skip + + rows.push({ + host: host, + visible_name: ($(this).find('.input-visible-name').val() || '').trim(), + ip: ip, + port: ($(this).find('.input-port').val() || '').trim(), + groups: ($(this).find('.input-groups').val() || '').trim(), + templates: ($(this).find('.input-templates').val() || '').trim(), + tags: ($(this).find('.input-tags').val() || '').trim(), + macros: ($(this).find('.input-macros').val() || '').trim(), + description: ($(this).find('.input-description').val() || '').trim() + }); + }); + return rows; + } + + // ===== Utility Functions ===== + function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // ===== Initialize ===== + // Handle return-from-preview: restore source host selection + table data. + var returnHostData = data.returnHostData || []; + var returnSourceHostid = data.returnSourceHostid || ''; + + if (returnSourceHostid && returnHostData.length > 0) { + // Find the source host in the allHosts list to restore its tag. + for (var i = 0; i < allHosts.length; i++) { + if (String(allHosts[i].hostid) === String(returnSourceHostid)) { + selectedHosts = [{ + id: String(allHosts[i].hostid), + name: allHosts[i].name, + host: allHosts[i].host || '' + }]; + renderHostTags(); + break; + } + } + + // Switch to the online table tab and populate rows. + $('.tab-btn').removeClass('active'); + $('.tab-btn[data-tab="table"]').addClass('active'); + $('.tab-content').removeClass('active').hide(); + $('#tab-table').addClass('active').show(); + + populateTableFromRows(returnHostData); + } + + // Populate source host list (all hosts initially) + applyFilters(); + + // Start with one empty row (only if table body is empty and no return data) + if ($('#host-data-tbody tr').length === 0) { + addTableRow(); + } + updatePreviewButton(); +}); + +})(jQuery); diff --git a/zabbix_clonehosts/assets/js/clonehosts.preview.js.php b/zabbix_clonehosts/assets/js/clonehosts.preview.js.php new file mode 100644 index 0000000..fa6e562 --- /dev/null +++ b/zabbix_clonehosts/assets/js/clonehosts.preview.js.php @@ -0,0 +1,256 @@ + + +(function($) { + 'use strict'; + + $(function() { + var config = window.clonehostsPreview || {}; + var hostData = config.hostData || []; + var totalCount = config.totalCount || 0; + var sourceHostid = config.sourceHostid || ''; + var ajaxUrl = config.ajaxUrl || 'zabbix.php?action=clonehosts.import'; + + // ===== i18n ===== + var L = config.lang || {}; + + function t(key) { + return L[key] || key; + } + + var successCount = 0; + var failedCount = 0; + var processedCount = 0; + var results = []; + var importing = false; + var importQueue = []; // Only hosts selected for import + + // ===== Update import button label based on selected rows ===== + function updateImportButton() { + var selectedCount = $('#preview-table input.row-select:checked').length; + $('#start-import-btn').text(t('preview.import_selected').replace('{count}', selectedCount)); + } + + function updateSelectAllCheckbox() { + var $readyBoxes = $('#preview-table input.row-select[data-ready="1"]'); + var totalReady = $readyBoxes.length; + var checkedReady = $readyBoxes.filter(':checked').length; + $('#preview-select-all').prop('checked', totalReady > 0 && totalReady === checkedReady); + } + + // Select / deselect all ready rows + $('#preview-select-all').on('change', function() { + var checked = $(this).prop('checked'); + $('#preview-table input.row-select[data-ready="1"]').prop('checked', checked); + updateImportButton(); + }); + + // Re-count when a checkbox changes + $(document).on('change', '#preview-table input.row-select', function() { + updateSelectAllCheckbox(); + updateImportButton(); + }); + + // Initialize select-all state + updateSelectAllCheckbox(); + + // ===== Start Import ===== + $('#start-import-btn').on('click', function() { + if (importing) return; + + // Collect only checked & ready rows + importQueue = []; + $('#preview-table tbody tr').each(function() { + var $cb = $(this).find('input.row-select'); + if ($cb.length && $cb.prop('checked') && $cb.data('ready') == 1) { + var idx = parseInt($(this).data('index'), 10); + if (!isNaN(idx) && hostData[idx]) { + importQueue.push({ rowNum: idx + 1, host: hostData[idx] }); + } + } + }); + + if (importQueue.length === 0) { + alert(t('preview.no_selected')); + return; + } + + importing = true; + $('#start-import-btn').prop('disabled', true).text(t('import.importing')); + $('#import-progress-section').show(); + $('#import-results-section').hide(); + + // Reset counters + successCount = 0; + failedCount = 0; + processedCount = 0; + results = []; + $('#results-tbody').empty(); + $('#progress-bar').css('width', '0%'); + $('#progress-text').text('0 / ' + importQueue.length); + $('#total-count').text(importQueue.length); + + // Start processing hosts sequentially + processNextHost(0); + }); + + function processNextHost(queueIndex) { + if (queueIndex >= importQueue.length) { + // All done + finishImport(); + return; + } + + var item = importQueue[queueIndex]; + var rowNum = item.rowNum; + var host = item.host; + + $.ajax({ + url: ajaxUrl, + method: 'POST', + data: { + source_hostid: sourceHostid, + host_data: host + }, + dataType: 'json', + success: function(resp) { + processedCount++; + + if (resp.success) { + successCount++; + addResultRow(rowNum, 'success', host.host, host.ip, resp.hostid || '', ''); + results.push({ + row: rowNum, + result: 'success', + host: host.host, + ip: host.ip, + hostid: resp.hostid || '', + error: '' + }); + } else { + failedCount++; + var errorMsg = resp.error || t('import.unknown_error'); + addResultRow(rowNum, 'failed', host.host, host.ip, '', errorMsg); + results.push({ + row: rowNum, + result: 'failed', + host: host.host, + ip: host.ip, + hostid: '', + error: errorMsg + }); + } + + updateProgress(); + }, + error: function(xhr, status, error) { + processedCount++; + failedCount++; + var errorMsg = t('import.ajax_error') + (error || status || t('import.unknown_error')); + addResultRow(rowNum, 'failed', host.host, host.ip, '', errorMsg); + results.push({ + row: rowNum, + result: 'failed', + host: host.host, + ip: host.ip, + hostid: '', + error: errorMsg + }); + updateProgress(); + }, + complete: function() { + // Process next host (small delay to allow UI update) + setTimeout(function() { + processNextHost(queueIndex + 1); + }, 50); + } + }); + } + + function updateProgress() { + var percent = Math.round((processedCount / importQueue.length) * 100); + $('#progress-bar').css('width', percent + '%'); + $('#progress-text').text(processedCount + ' / ' + importQueue.length); + $('#progress-success').text(successCount); + $('#progress-failed').text(failedCount); + $('#progress-pending').text(importQueue.length - processedCount); + $('#success-count').text(successCount); + $('#failed-count').text(failedCount); + } + + function addResultRow(rowNum, result, hostName, ip, hostid, error) { + var resultClass = result === 'success' ? 'result-success' : 'result-failed'; + var resultIcon = result === 'success' + ? '✓ ' + t('import.result_success') + : '✗ ' + t('import.result_failed'); + + var html = '' + + '' + rowNum + '' + + '' + resultIcon + '' + + '' + escapeHtml(hostName) + '' + + '' + escapeHtml(ip) + '' + + '' + (hostid ? escapeHtml(hostid) : '-') + '' + + '' + (error ? '' + escapeHtml(error) + '' : '-') + '' + + ''; + + $('#results-tbody').append(html); + } + + function finishImport() { + importing = false; + $('#start-import-btn').text(t('import.complete')); + $('#import-results-section').show(); + + // Show/hide download report button + if (results.length > 0) { + $('#download-report-btn').show(); + } + + // Scroll to results + $('html, body').animate({ + scrollTop: $('#import-results-section').offset().top - 50 + }, 500); + } + + // ===== Download Report ===== + $('#download-report-btn').on('click', function() { + var csv = t('report.col_row') + ',' + t('report.col_result') + ',' + t('report.col_host') + ',' + t('report.col_ip') + ',' + t('report.col_hostid') + ',' + t('report.col_error') + '\n'; + $.each(results, function(i, r) { + csv += r.row + ',' + + r.result + ',' + + '"' + r.host.replace(/"/g, '""') + '",' + + '"' + r.ip.replace(/"/g, '""') + '",' + + (r.hostid || '') + ',' + + '"' + (r.error || '').replace(/"/g, '""') + '"\n'; + }); + + var blob = new Blob(['\ufeff' + csv], {type: 'text/csv;charset=utf-8;'}); + var link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = 'host_import_report_' + Date.now() + '.csv'; + link.click(); + }); + + // ===== Utility ===== + function escapeHtml(str) { + if (str === null || str === undefined) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + }); + +})(jQuery); diff --git a/zabbix_clonehosts/clonehosts_template.csv b/zabbix_clonehosts/clonehosts_template.csv new file mode 100644 index 0000000..0304043 --- /dev/null +++ b/zabbix_clonehosts/clonehosts_template.csv @@ -0,0 +1,3 @@ +主机名称(*),可见的名称,接口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 diff --git a/zabbix_clonehosts/images/image-1.png b/zabbix_clonehosts/images/image-1.png new file mode 100644 index 0000000..9d199e0 Binary files /dev/null and b/zabbix_clonehosts/images/image-1.png differ diff --git a/zabbix_clonehosts/images/image-2.png b/zabbix_clonehosts/images/image-2.png new file mode 100644 index 0000000..632b431 Binary files /dev/null and b/zabbix_clonehosts/images/image-2.png differ diff --git a/zabbix_clonehosts/images/image-3.png b/zabbix_clonehosts/images/image-3.png new file mode 100644 index 0000000..4eeab26 Binary files /dev/null and b/zabbix_clonehosts/images/image-3.png differ diff --git a/zabbix_clonehosts/images/image-4.png b/zabbix_clonehosts/images/image-4.png new file mode 100644 index 0000000..aa412f2 Binary files /dev/null and b/zabbix_clonehosts/images/image-4.png differ diff --git a/zabbix_clonehosts/images/image.png b/zabbix_clonehosts/images/image.png new file mode 100644 index 0000000..fab29c1 Binary files /dev/null and b/zabbix_clonehosts/images/image.png differ diff --git a/zabbix_clonehosts/manifest.json b/zabbix_clonehosts/manifest.json new file mode 100644 index 0000000..e9e5942 --- /dev/null +++ b/zabbix_clonehosts/manifest.json @@ -0,0 +1,37 @@ +{ + "manifest_version": 2.0, + "id": "zabbix-clonehosts", + "name": "Zabbix Clonehosts", + "author": "火星小刘/先龍", + "url": "https://github.com/X-Mars/zabbix_modules", + "namespace": "ZabbixClonehosts", + "version": "1.3.0", + "description": "Batch clone/import hosts based on an existing monitored host. Supports CSV/online table entry, preview with conflict detection, auto-creation of host groups, existence status indicators, and progress feedback. Compatible with Zabbix 6.0/6.4/7.x. Auto-syncs language with Zabbix system settings (Chinese/English).", + "actions": { + "clonehosts": { + "class": "Clonehosts", + "view": "clonehosts" + }, + "clonehosts.preview": { + "class": "ClonehostsPreview", + "view": "clonehosts.preview" + }, + "clonehosts.import": { + "class": "ClonehostsImport", + "view": "clonehosts.import" + }, + "clonehosts.source": { + "class": "ClonehostsSource", + "view": "clonehosts.import" + } + }, + "assets": { + "js": [ + "clonehosts.js.php", + "clonehosts.preview.js.php" + ], + "css": [ + "clonehosts.css" + ] + } +} diff --git a/zabbix_clonehosts/views/clonehosts.import.php b/zabbix_clonehosts/views/clonehosts.import.php new file mode 100644 index 0000000..5b65fb0 --- /dev/null +++ b/zabbix_clonehosts/views/clonehosts.import.php @@ -0,0 +1,30 @@ +/ + + + + diff --git a/zabbix_clonehosts/views/clonehosts.preview.php b/zabbix_clonehosts/views/clonehosts.preview.php new file mode 100644 index 0000000..c513564 --- /dev/null +++ b/zabbix_clonehosts/views/clonehosts.preview.php @@ -0,0 +1,333 @@ + LangHelper::t('iface.Agent'), 2 => LangHelper::t('iface.SNMP'), 3 => LangHelper::t('iface.IPMI'), 4 => LangHelper::t('iface.JMX')]; + $type_name = $type_names[$iface['type']] ?? (LangHelper::t('iface.Type') . $iface['type']); + $src_interfaces[] = htmlspecialchars($type_name . ': ' . ($iface['useip'] ? $iface['ip'] : $iface['dns']) . ':' . $iface['port']); + } +} + +// Prepare host data for JavaScript (for AJAX import). +$js_host_data = []; +foreach ($host_data as $item) { + $js_host_data[] = [ + 'host' => $item['host'] ?? '', + 'visible_name' => $item['visible_name'] ?? '', + 'ip' => $item['ip'] ?? '', + 'port' => $item['port'] ?? '', + 'groups' => $item['groups'] ?? '', + 'templates' => $item['templates'] ?? '', + 'tags' => $item['tags'] ?? '', + 'macros' => $item['macros'] ?? '', + 'description' => $item['description'] ?? '' + ]; +} +?> + + + + + +
+ +
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
', $src_interfaces) : LangHelper::t('preview.none') ?>
+
+
+ + +
+
+ 0): ?> + + + + + + + +
+
+ + +
+

+
+ + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + !' + : '✓'; + $fstatus = $field_status[$idx - 1] ?? []; + // Conflict rows: disabled & unchecked; ready rows: checked by default. + $checkbox = $has_conflict + ? '' + : ''; + ?> + + + + + + + + + + + + + + + + +
#
' . LangHelper::t('preview.inherited') . '' ?>' . LangHelper::t('preview.inherited') . '' ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ' . LangHelper::t('preview.inherited') . '' ?>
+
+
+ + +
+
+
+ + + + +
+ + 0): ?> + + +
+
+ + + +
+ + + + + + diff --git a/zabbix_cmdb/README.md b/zabbix_cmdb/README.md index e1490be..6c9757c 100644 --- a/zabbix_cmdb/README.md +++ b/zabbix_cmdb/README.md @@ -50,7 +50,7 @@ # Zabbix 6.0 / 7.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 部署方法 +# Zabbix 7.2+ / 7.4 / 8.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_cmdb/README_en.md b/zabbix_cmdb/README_en.md index 92d3aa8..6637610 100644 --- a/zabbix_cmdb/README_en.md +++ b/zabbix_cmdb/README_en.md @@ -50,7 +50,7 @@ This is a Zabbix frontend module that provides Configuration Management Database # Zabbix 6.0 / 7.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 deployment +# Zabbix 7.2+ / 7.4 / 8.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_graphtrees/README.md b/zabbix_graphtrees/README.md index bbde88d..c67229f 100644 --- a/zabbix_graphtrees/README.md +++ b/zabbix_graphtrees/README.md @@ -48,7 +48,7 @@ # Zabbix 6.0 / 7.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 部署方法 +# Zabbix 7.2+ / 7.4 / 8.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_graphtrees/README_en.md b/zabbix_graphtrees/README_en.md index 322db53..55a3f95 100644 --- a/zabbix_graphtrees/README_en.md +++ b/zabbix_graphtrees/README_en.md @@ -47,7 +47,7 @@ This is a Zabbix frontend module that provides tree-structured resource browsing # Zabbix 6.0 / 7.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 deployment +# Zabbix 7.2+ / 7.4 / 8.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_im/README.md b/zabbix_im/README.md index 4c035e9..658f474 100644 --- a/zabbix_im/README.md +++ b/zabbix_im/README.md @@ -43,7 +43,7 @@ # Zabbix 6.0 / 7.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 部署方法 +# Zabbix 7.2+ / 7.4 / 8.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_im/README_en.md b/zabbix_im/README_en.md index 2e5a39c..b61fe37 100644 --- a/zabbix_im/README_en.md +++ b/zabbix_im/README_en.md @@ -43,7 +43,7 @@ The module adds an **IM Sync Assistant** submenu under the **Users** menu in Zab # Zabbix 6.0 / 7.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 deployment +# Zabbix 7.2+ / 7.4 / 8.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_jumpserver/README.md b/zabbix_jumpserver/README.md index 0feaf00..4d1d5bc 100644 --- a/zabbix_jumpserver/README.md +++ b/zabbix_jumpserver/README.md @@ -100,7 +100,7 @@ # Zabbix 6.0 / 7.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 部署方法 +# Zabbix 7.2+ / 7.4 / 8.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_jumpserver/README_en.md b/zabbix_jumpserver/README_en.md index 238da45..56a04d9 100644 --- a/zabbix_jumpserver/README_en.md +++ b/zabbix_jumpserver/README_en.md @@ -100,7 +100,7 @@ Useful when assets already exist in JumpServer but Zabbix hosts do not yet have # Zabbix 6.0 / 7.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 deployment +# Zabbix 7.2+ / 7.4 / 8.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_rack/README.md b/zabbix_rack/README.md index 1885d09..b9d248a 100644 --- a/zabbix_rack/README.md +++ b/zabbix_rack/README.md @@ -66,7 +66,7 @@ # Zabbix 6.0 / 7.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 部署方法 +# Zabbix 7.2+ / 7.4 / 8.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_rack/README_en.md b/zabbix_rack/README_en.md index 3068918..8e5c870 100644 --- a/zabbix_rack/README_en.md +++ b/zabbix_rack/README_en.md @@ -66,7 +66,7 @@ This is a Zabbix frontend module for data center rack visualization and host pla # Zabbix 6.0 / 7.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 deployment +# Zabbix 7.2+ / 7.4 / 8.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_reports/README.md b/zabbix_reports/README.md index 053183b..a7859e6 100644 --- a/zabbix_reports/README.md +++ b/zabbix_reports/README.md @@ -47,7 +47,7 @@ # Zabbix 6.0 / 7.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 部署方法 +# Zabbix 7.2+ / 7.4 / 8.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_reports/README_en.md b/zabbix_reports/README_en.md index 1c3b5ec..cadc55c 100644 --- a/zabbix_reports/README_en.md +++ b/zabbix_reports/README_en.md @@ -47,7 +47,7 @@ Zabbix Reports is a frontend module for Zabbix that generates daily, weekly, and # 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 +# For Zabbix 7.2+ / 7.4 / 8.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_snmp/README.md b/zabbix_snmp/README.md index 32814a8..d7e8c83 100644 --- a/zabbix_snmp/README.md +++ b/zabbix_snmp/README.md @@ -56,7 +56,7 @@ # Zabbix 6.0 / 7.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 部署方法 +# Zabbix 7.2+ / 7.4 / 8.0 部署方法 git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ``` diff --git a/zabbix_snmp/README_en.md b/zabbix_snmp/README_en.md index df0f5b9..876c6e7 100644 --- a/zabbix_snmp/README_en.md +++ b/zabbix_snmp/README_en.md @@ -56,7 +56,7 @@ This is a Zabbix frontend module that provides an SNMP assistant. It adds an **S # Zabbix 6.0 / 7.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/modules/ -# Zabbix 7.4 / 8.0 deployment +# Zabbix 7.2+ / 7.4 / 8.0 deployment git clone https://github.com/X-Mars/zabbix_modules.git /usr/share/zabbix/ui/modules/ ```