diff --git a/zabbix_ipam/README.md b/zabbix_ipam/README.md index 136bc83..6889ce3 100644 --- a/zabbix_ipam/README.md +++ b/zabbix_ipam/README.md @@ -1,33 +1,153 @@ -# Zabbix IPAM +# Zabbix IPAM 模块 -面向 Zabbix 6.0、7.x 与 8.0 的前端 IP 地址管理模块。它管理 CIDR/IPv4 起止地址段,通过 ICMP 异步扫描存活主机,并把结果与 Zabbix 主机接口自动匹配。 +[English](README_en.md) -## 安装 +## ✨ 版本兼容性 -1. 将整个 `zabbix_ipam` 目录复制至模块目录:Zabbix 6.0/7.0 通常为 `/usr/share/zabbix/modules`;7.2+/8.0 请以 `zabbix.conf.php` 的 `$ZBX_MODULES_DIR` 为准。 -2. 让 Web 服务器用户可写 `zabbix_ipam/data`(包括 `scan_tasks` 与 `results`)。例如:`chown -R www-data:www-data zabbix_ipam/data`。 -3. 在 **Administration → General → Modules** 启用 IPAM。入口位于 **Inventory → IP 管理 / IPAM**。 +本模块兼容 Zabbix 6.0 / 7.0+ / 8.0+ 版本。 + +- ✅ Zabbix 6.0.x +- ✅ Zabbix 7.0.x +- ✅ Zabbix 7.4.x +- ✅ Zabbix 8.0.x + +**兼容性说明**:模块内置 Zabbix 版本检测与自定义 `LanguageManager`,自动适配不同命名空间、菜单接口及用户语言设置,无需手动选择中英文。 + +## 描述 + +这是一个 Zabbix 前端 IP 地址管理模块,用于集中维护 IPv4 地址段、执行异步 ICMP 存活扫描,并根据 Zabbix 主机接口 IP 自动建立主机关联。模块在 Zabbix Web 的资产记录菜单下新增“IP 管理”菜单,包含“IP 管理”“IP 详情”和“任务管理”三个页面。 + +扫描仅使用 ICMP,不会连接目标 TCP 端口。大网段会自动分片,并由后台 PHP CLI 任务执行,避免阻塞 Zabbix Web 请求。 + +![1](images/1.png) +![2](images/2.png) +![3](images/3.png) + +## 功能特性 + +- **IP 段管理**:支持 CIDR 和 IPv4 起止地址格式,可添加、修改和删除 IP 段 +- **自动筛选**:搜索框按 Enter 自动筛选,下拉框选择后自动提交 +- **ICMP 存活扫描**:使用 `fping` 检测地址是否可达,不进行 TCP 端口探测 +- **异步任务**:Web 页面创建任务后由 PHP CLI 在后台执行 +- **分片与多进程**:默认每 64 个地址一个分片,支持 `pcntl_fork` 并行扫描 +- **实时进度**:显示已扫描数量、存活数量、完成分片数和任务状态 +- **定时扫描**:扫描间隔由 crontab 表达式统一设置,每次执行时扫描所有已启用的 IP 段 +- **IP 使用矩阵**:以绿色和灰色方块展示地址存活状态 +- **IP 详情列表**:展示 IP、IP 段名称、IP 段、存活状态及主机关联信息 +- **主机自动关联**:根据 Zabbix 主机接口 IPv4 地址自动匹配主机 +- **最新数据跳转**:列表主机名称和已关联的矩阵存活 IP 均可进入对应主机的最新数据页面 +- **列表分页**:所有列表均支持分页和每页显示数量选择 +- **国际化支持**:自动跟随 Zabbix 用户语言,支持简体中文和英文 +- **响应式设计**:取消固定最大宽度,适配普通、宽屏和窄屏浏览器 +- **安全限制**:仅 Zabbix 管理员可访问,输入均经过 IPv4 和数值校验 + +## 安装步骤 + +### 安装模块 + +```bash +# Zabbix 6.0 / 7.0 常用模块路径 +cp -a zabbix_ipam /usr/share/zabbix/modules/ + +# Zabbix 7.2+ / 7.4 / 8.0 常用模块路径 +cp -a zabbix_ipam /usr/share/zabbix/ui/modules/ +``` + +实际路径请以 Zabbix 前端配置中的 `$ZBX_MODULES_DIR` 为准。 + +### ⚠️ 修改 manifest.json 文件 + +```bash +# ⚠️ 仅在 Zabbix 6.0 无法识别 manifest_version 2.0 时执行 +sed -i 's/"manifest_version": 2.0/"manifest_version": 1.0/' zabbix_ipam/manifest.json +``` + +### 配置目录权限 + +运行 Web 服务和 cron 的用户必须能够读写 `data/`、`data/scan_tasks/` 和 `data/results/`。 + +```bash +# Debian / Ubuntu 示例 +chown -R www-data:www-data zabbix_ipam/data +chmod -R 770 zabbix_ipam/data + +# RHEL / Rocky Linux 示例 +chown -R apache:apache zabbix_ipam/data +chmod -R 770 zabbix_ipam/data +``` + +### 安装扫描依赖 + +```bash +# Debian / Ubuntu +apt install fping php-cli php-pcntl + +# RHEL / Rocky Linux +dnf install fping php-cli php-process +``` + +`php-pcntl` 或 `php-process` 用于多进程扫描;未安装 `pcntl` 时会自动降级为后台串行扫描。PHP CLI 和 `fping` 是后台扫描的必要依赖。 + +### 启用模块 + +1. 转到 **Administration → General → Modules**。 +2. 点击 **Scan directory** 按钮扫描新模块。 +3. 找到 “IPAM” 模块并启用。 +4. 刷新页面,模块将在 **Inventory** 菜单下显示为“IP 管理”,包含“IP 管理”“IP 详情”和“任务管理”三个子项。 ## 定时扫描 -每个 IP 段可设置独立扫描间隔(分钟);启用后由唯一 cron 入口检查到期任务: +`cli/scan_cron.php` 是唯一定时扫描入口,crontab 的执行频率就是扫描间隔。以下示例每 5 分钟扫描一次所有已启用的 IP 段;已有待处理或运行中的任务不会被重复创建。 ```cron -*/5 * * * * www-data /usr/bin/php /usr/share/zabbix/modules/zabbix_ipam/cli/scan_cron.php >> /var/log/zabbix/ipam-cron.log 2>&1 +*/5 * * * * apache /usr/bin/php /usr/share/zabbix/modules/zabbix_ipam/cli/scan_cron.php >> /var/log/zabbix/ipam-cron.log 2>&1 ``` -扫描任务使用状态文件避免同一 IP 段重复提交。Web 页面启动扫描时会后台执行同一 CLI;前端每 1.5 秒轮询任务进度。若后台启动在受限 PHP 环境中被禁止,可仅使用以上 cron 调度。 +Debian / Ubuntu 通常将运行用户改为 `www-data`。任务状态文件和工作锁会避免同一 IP 段被重复提交。 + +## 页面说明 + +- **IP 管理**:添加和维护 IP 段、启动扫描、查看扫描任务和打开 IP 使用矩阵 +- **IP 详情**:按关键词、IP 段、IP 状态和主机关联状态筛选所有地址 +- **任务管理**:查看待处理、运行中、已完成、失败或已停止的后台扫描任务 + +点击“存活 IP / IP 总数”可打开 IP 使用矩阵。绿色表示最近一次 ICMP 扫描存活,灰色表示不可达或尚未扫描;绿色地址已匹配 Zabbix 主机时可以直接点击进入该主机的最新数据页面。 + +## 注意事项 -## 扫描与限制 +- **授权范围**:请仅扫描已获授权的网络。 +- **地址限制**:仅支持 IPv4,单个 IP 段最多 65,536 个地址。 +- **系统权限**:Zabbix Web 用户和 cron 用户必须能够写入模块 `data/` 目录。 +- **ICMP 权限**:请确保服务器防火墙允许 ICMP,并允许 `fping` 正常运行。 +- **性能考虑**:大网段会分片处理;并行工作进程默认最多为 4 个。 +- **主机匹配**:关联结果来自当前用户有权访问的 Zabbix 主机接口数据。 +- **数据存储**:当前使用 JSON 文件,生产环境请定期备份 `data/` 目录。 -CIDR 会默认按 64 个地址分片(可通过 AJAX 参数 `shard_size` 指定)。CLI 上有 `pcntl` 时最多同时运行 4 个 fork 子进程,每个分片以独立临时 JSON 结果文件回传;没有 `pcntl` 时安全地串行回退。扫描仅使用 `fping` ICMP 探测,不会连接目标 TCP 端口。后台任务及 cron 需要 PHP CLI;多进程需要 `php-process`/`pcntl`。 +## 开发 -仅接受 IPv4,单个范围最多 65,536 个地址。请只扫描已获授权的网络;生产中建议将 `max_execution_time`、防火墙速率和 cron 的运行用户纳入运维策略。模块操作限制为 Zabbix 管理员。 +插件基于 Zabbix 模块框架开发。主要文件结构: -JSON 存储封装在 `lib/IpStorage.php`,可在后续替换为数据库实现而不改变控制器和扫描器。 +- `manifest.json`:模块信息与页面路由 +- `Module.php`:Inventory 菜单注册 +- `actions/IpManager.php`:IP 段列表业务逻辑 +- `actions/IpDetail.php`:IP 详情、主机关联筛选与分页 +- `actions/IpScan.php`:扫描任务列表业务逻辑 +- `actions/IpAjax.php`:保存、删除、扫描及矩阵 AJAX 接口 +- `views/ip.manager.php`:IP 管理页面 +- `views/ip.detail.php`:IP 详情页面 +- `views/ip.scan.php`:任务管理页面 +- `lib/HostMatcher.php`:Zabbix 主机接口 IP 匹配 +- `lib/IpScanner.php`:ICMP、分片和多进程扫描核心 +- `lib/IpStorage.php`:JSON 数据读写 +- `lib/TaskManager.php`:后台任务和定时调度 +- `lib/LanguageManager.php`:中英文语言管理 +- `lib/ViewRenderer.php`:视图渲染 +- `lib/ZabbixVersion.php`:版本兼容检测 +- `assets/`:页面样式与交互脚本 +- `cli/scan_cron.php`:cron 扫描入口 -## 页面使用 +如需扩展,可参考 [Zabbix 模块开发文档](https://www.zabbix.com/documentation/7.0/en/devel/modules)。 -模块提供三个业务页面:**IP 管理**、**IP 详情**与**任务管理**。IP 管理页上方可按名称/CIDR、IP 段和状态筛选;“添加 IP 段”和“修改”共用同一弹窗,只需填写名称、IPv4 CIDR(或起止地址)、扫描间隔并选择是否启用定时扫描。每行均可启动扫描、查看扫描任务、修改或删除。 +## 许可证 -点击“存活 IP / IP 总数”会打开 IP 使用矩阵。绿色方块表示最近一次扫描中可通过 ICMP 到达,灰色方块表示不可达或尚未扫描;绿色地址已匹配 Zabbix 主机时可直接点击进入主机详情。IP 详情页按 IP、IP 段、存活状态和主机关联状态筛选所有地址,并自动按照 Zabbix 主机接口 IP 建立关联。任务管理页集中显示后台任务状态、扫描进度、存活数及已完成分片数;页面会自动轮询运行中的任务,无需手动刷新。 +本项目遵循 Zabbix 的许可证。详情请见 [Zabbix 许可证](https://www.zabbix.com/license)。 diff --git a/zabbix_ipam/actions/IpAjax.php b/zabbix_ipam/actions/IpAjax.php index db378d1..d268733 100644 --- a/zabbix_ipam/actions/IpAjax.php +++ b/zabbix_ipam/actions/IpAjax.php @@ -37,7 +37,6 @@ protected function checkInput(): bool { 'name' => 'string', 'range' => 'string', 'enabled' => 'in 0,1', - 'scan_interval' => 'int32', 'shard_size' => 'int32' ]); } @@ -60,7 +59,6 @@ protected function doAction(): void { 'name' => trim($this->getInput('name', '')) ?: $value, 'range' => $value, 'enabled' => $this->getInput('enabled', '1') === '1', - 'scan_interval' => max(1, $this->getInput('scan_interval', 60)), 'ports' => '' ]); $output = ['ok' => true, 'message' => LanguageManager::t('Range saved.'), 'range' => $range]; diff --git a/zabbix_ipam/actions/IpDetail.php b/zabbix_ipam/actions/IpDetail.php index c8182f7..c68a542 100644 --- a/zabbix_ipam/actions/IpDetail.php +++ b/zabbix_ipam/actions/IpDetail.php @@ -15,8 +15,6 @@ require_once dirname(__DIR__).'/lib/LanguageManager.php'; class IpDetail extends CController { - private const PAGE_SIZE = 50; - public function init(): void { if (method_exists($this, 'disableCsrfValidation')) { $this->disableCsrfValidation(); @@ -32,7 +30,8 @@ protected function checkInput(): bool { 'rangeid' => 'string', 'status' => 'in all,alive,down,unscanned', 'associated' => 'in all,yes,no', - 'page' => 'int32' + 'page' => 'int32', + 'per_page' => 'in 20,50,100,200' ]); } @@ -47,8 +46,9 @@ protected function doAction(): void { $status = $this->getInput('status', 'all'); $associated = $this->getInput('associated', 'all'); $search = mb_strtolower(trim($this->getInput('search', ''))); + $per_page = (int) $this->getInput('per_page', 50); $page = max(1, (int) $this->getInput('page', 1)); - $offset = ($page - 1) * self::PAGE_SIZE; + $offset = ($page - 1) * $per_page; $latest = []; foreach ($storage->tasks() as $task) { @@ -100,13 +100,14 @@ protected function doAction(): void { continue; } - if ($total >= $offset && count($rows) < self::PAGE_SIZE) { + if ($total >= $offset && count($rows) < $per_page) { $rows[] = [ 'ip' => $ip, 'range_id' => $range['id'], 'range_name' => $range['name'], 'range' => $range['range'], 'state' => $state, + 'status_updated_at' => $known[$ip]['scanned_at'] ?? '', 'hosts' => $hosts, 'associated' => $is_associated ]; @@ -115,7 +116,7 @@ protected function doAction(): void { } } - $pages = max(1, (int) ceil($total / self::PAGE_SIZE)); + $pages = max(1, (int) ceil($total / $per_page)); $page = min($page, $pages); $this->setResponse(new CControllerResponseData([ @@ -132,7 +133,7 @@ protected function doAction(): void { 'page' => $page, 'pages' => $pages, 'total' => $total, - 'page_size' => self::PAGE_SIZE + 'per_page' => $per_page ] ])); } diff --git a/zabbix_ipam/actions/IpManager.php b/zabbix_ipam/actions/IpManager.php index 7d3386a..3261c0c 100644 --- a/zabbix_ipam/actions/IpManager.php +++ b/zabbix_ipam/actions/IpManager.php @@ -1,11 +1,107 @@ disableCsrfValidation();elseif(method_exists($this,'disableSIDvalidation'))$this->disableSIDvalidation();} - protected function checkInput():bool{return $this->validateInput(['rangeid'=>'string','status'=>'in all,enabled,disabled,pending,running,completed,failed','search'=>'string']);} - protected function checkPermissions():bool{return $this->getUserType()>=USER_TYPE_ZABBIX_ADMIN;} - protected function doAction():void{$s=new IpStorage();$tasks=$s->tasks();$latest=[];foreach($tasks as $task)if(!isset($latest[$task['range_id']]))$latest[$task['range_id']]=$task;$rows=[];foreach($s->ranges() as $range){try{$parsed=IpScanner::parseRange($range['range']);$total=$parsed['count'];}catch(\Throwable $e){$total=0;}$task=$latest[$range['id']]??null;$rows[]=['range'=>$range,'task'=>$task,'total'=>$task['total']??$total,'alive'=>$task['alive']??0,'scanned'=>$task['scanned']??0,'task_status'=>$task['status']??'never'];}$selected=$this->getInput('rangeid','');$status=$this->getInput('status','all');$search=mb_strtolower(trim($this->getInput('search','')));$rows=array_values(array_filter($rows,function($row)use($selected,$status,$search){$r=$row['range'];$matchStatus=$status==='all'||($status==='enabled'&&!empty($r['enabled']))||($status==='disabled'&&empty($r['enabled']))||$row['task_status']===$status;return(!$selected||$r['id']===$selected)&&$matchStatus&&(!$search||strpos(mb_strtolower(($r['name']??'').' '.($r['range']??'')),$search)!==false);}));$this->setResponse(new CControllerResponseData(['title'=>LanguageManager::t('IPAM'),'rows'=>$rows,'all_ranges'=>$s->ranges(),'filters'=>['rangeid'=>$selected,'status'=>$status,'search'=>$search]]));} + public function init(): void { + if (method_exists($this, 'disableCsrfValidation')) { + $this->disableCsrfValidation(); + } + elseif (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + } + + protected function checkInput(): bool { + return $this->validateInput([ + 'rangeid' => 'string', + 'enabled_status' => 'in all,enabled,disabled', + 'scan_status' => 'in all,never,pending,running,completed,failed,stopped', + 'search' => 'string', + 'page' => 'int32', + 'per_page' => 'in 20,50,100,200' + ]); + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + $storage = new IpStorage(); + $all_ranges = $storage->ranges(); + $latest = []; + foreach ($storage->tasks() as $task) { + if (!isset($latest[$task['range_id']])) { + $latest[$task['range_id']] = $task; + } + } + + $selected = $this->getInput('rangeid', ''); + $enabled_status = $this->getInput('enabled_status', 'all'); + $scan_status = $this->getInput('scan_status', 'all'); + $search = mb_strtolower(trim($this->getInput('search', ''))); + $per_page = (int) $this->getInput('per_page', 20); + $page = max(1, (int) $this->getInput('page', 1)); + $rows = []; + + foreach ($all_ranges as $range) { + try { + $total = IpScanner::parseRange($range['range'])['count']; + } + catch (\Throwable $exception) { + $total = 0; + } + + $task = $latest[$range['id']] ?? null; + $row = [ + 'range' => $range, + 'task' => $task, + 'total' => $task['total'] ?? $total, + 'alive' => $task['alive'] ?? 0, + 'scanned' => $task['scanned'] ?? 0, + 'task_status' => $task['status'] ?? 'never', + 'last_scan' => $range['last_scan'] ?? ($task['finished_at'] ?? '') + ]; + + $matches_enabled = $enabled_status === 'all' + || ($enabled_status === 'enabled' && !empty($range['enabled'])) + || ($enabled_status === 'disabled' && empty($range['enabled'])); + $matches_scan = $scan_status === 'all' || $row['task_status'] === $scan_status; + $matches_search = $search === '' + || strpos(mb_strtolower(($range['name'] ?? '').' '.($range['range'] ?? '')), $search) !== false; + + if (($selected === '' || $range['id'] === $selected) && $matches_enabled && $matches_scan && $matches_search) { + $rows[] = $row; + } + } + + $total_rows = count($rows); + $pages = max(1, (int) ceil($total_rows / $per_page)); + $page = min($page, $pages); + $rows = array_slice($rows, ($page - 1) * $per_page, $per_page); + + $this->setResponse(new CControllerResponseData([ + 'title' => LanguageManager::t('IPAM'), + 'rows' => $rows, + 'all_ranges' => $all_ranges, + 'filters' => [ + 'rangeid' => $selected, + 'enabled_status' => $enabled_status, + 'scan_status' => $scan_status, + 'search' => $search + ], + 'pagination' => ['page' => $page, 'pages' => $pages, 'total' => $total_rows, 'per_page' => $per_page] + ])); + } } diff --git a/zabbix_ipam/actions/IpScan.php b/zabbix_ipam/actions/IpScan.php index 4b758c6..ad4a139 100644 --- a/zabbix_ipam/actions/IpScan.php +++ b/zabbix_ipam/actions/IpScan.php @@ -1,11 +1,83 @@ disableCsrfValidation();elseif(method_exists($this,'disableSIDvalidation'))$this->disableSIDvalidation();} - protected function checkInput():bool{return $this->validateInput(['taskid'=>'string','status'=>'in all,pending,running,completed,failed,stopped','search'=>'string']);} - protected function checkPermissions():bool{return $this->getUserType()>=USER_TYPE_ZABBIX_ADMIN;} - protected function doAction():void{$s=new IpStorage();$id=$this->getInput('taskid','');$status=$this->getInput('status','all');$search=mb_strtolower(trim($this->getInput('search','')));$names=[];foreach($s->ranges() as $r)$names[$r['id']]=$r['name'];$tasks=[];foreach($s->tasks() as $task){$task['range_name']=$names[$task['range_id']]??$task['range_id'];if($id&&$task['id']!==$id)continue;if($status!=='all'&&$task['status']!==$status)continue;if($search&&strpos(mb_strtolower($task['id'].' '.$task['range_name']),$search)===false)continue;$tasks[]=$task;}$this->setResponse(new CControllerResponseData(['title'=>LanguageManager::t('Task Management'),'tasks'=>$tasks,'selected'=>$id,'filters'=>['status'=>$status,'search'=>$search]]));} + public function init(): void { + if (method_exists($this, 'disableCsrfValidation')) { + $this->disableCsrfValidation(); + } + elseif (method_exists($this, 'disableSIDvalidation')) { + $this->disableSIDvalidation(); + } + } + + protected function checkInput(): bool { + return $this->validateInput([ + 'taskid' => 'string', + 'status' => 'in all,pending,running,completed,failed,stopped', + 'search' => 'string', + 'page' => 'int32', + 'per_page' => 'in 20,50,100,200' + ]); + } + + protected function checkPermissions(): bool { + return $this->getUserType() >= USER_TYPE_ZABBIX_ADMIN; + } + + protected function doAction(): void { + $storage = new IpStorage(); + $task_id = $this->getInput('taskid', ''); + $status = $this->getInput('status', 'all'); + $search = mb_strtolower(trim($this->getInput('search', ''))); + $per_page = (int) $this->getInput('per_page', 20); + $page = max(1, (int) $this->getInput('page', 1)); + + $range_names = []; + foreach ($storage->ranges() as $range) { + $range_names[$range['id']] = $range['name']; + } + + $tasks = []; + $summary = ['pending' => 0, 'running' => 0, 'completed' => 0, 'failed' => 0]; + foreach ($storage->tasks() as $task) { + if (isset($summary[$task['status']])) { + $summary[$task['status']]++; + } + $task['range_name'] = $range_names[$task['range_id']] ?? $task['range_id']; + if ($task_id !== '' && $task['id'] !== $task_id) { + continue; + } + if ($status !== 'all' && $task['status'] !== $status) { + continue; + } + if ($search !== '' && strpos(mb_strtolower($task['id'].' '.$task['range_name']), $search) === false) { + continue; + } + $tasks[] = $task; + } + + $total_rows = count($tasks); + $pages = max(1, (int) ceil($total_rows / $per_page)); + $page = min($page, $pages); + $tasks = array_slice($tasks, ($page - 1) * $per_page, $per_page); + + $this->setResponse(new CControllerResponseData([ + 'title' => LanguageManager::t('Task Management'), + 'tasks' => $tasks, + 'selected' => $task_id, + 'summary' => $summary, + 'filters' => ['status' => $status, 'search' => $search], + 'pagination' => ['page' => $page, 'pages' => $pages, 'total' => $total_rows, 'per_page' => $per_page] + ])); + } } diff --git a/zabbix_ipam/assets/css/ipam-responsive.css b/zabbix_ipam/assets/css/ipam-responsive.css index 66284ae..c2abd5a 100644 --- a/zabbix_ipam/assets/css/ipam-responsive.css +++ b/zabbix_ipam/assets/css/ipam-responsive.css @@ -1,6 +1,6 @@ .ipam { width: calc(100vw - 215px); - max-width: 1440px; + max-width: none; margin-left: 0; margin-right: 0; } @@ -33,27 +33,48 @@ } .ipam-range-table th:nth-child(1) { - width: 32%; + width: 4%; } .ipam-range-table th:nth-child(2) { - width: 20%; + width: 10%; } -.ipam-task-table th:nth-child(1) { width: 28%; } -.ipam-task-table th:nth-child(2) { width: 13%; } -.ipam-task-table th:nth-child(3) { width: 20%; } -.ipam-task-table th:nth-child(4) { width: 8%; } -.ipam-task-table th:nth-child(5) { width: 9%; } -.ipam-task-table th:nth-child(6) { width: 14%; } +.ipam-range-table th:nth-child(3) { width: 13%; } +.ipam-range-table th:nth-child(4) { width: 10%; } +.ipam-range-table th:nth-child(5) { width: 8%; } +.ipam-range-table th:nth-child(6) { width: 8%; } +.ipam-range-table th:nth-child(7) { width: 17%; } +.ipam-range-table th:nth-child(8) { width: 30%; } + +.ipam-table time { + white-space: nowrap; +} + +.ipam-task-table th:nth-child(1) { width: 4%; } +.ipam-task-table th:nth-child(2) { width: 20%; } +.ipam-task-table th:nth-child(3) { width: 11%; } +.ipam-task-table th:nth-child(4) { width: 10%; } +.ipam-task-table th:nth-child(5) { width: 16%; } +.ipam-task-table th:nth-child(6) { width: 7%; } .ipam-task-table th:nth-child(7) { width: 8%; } +.ipam-task-table th:nth-child(8) { width: 16%; } +.ipam-task-table th:nth-child(9) { width: 8%; } + +.ipam-detail-table th:nth-child(1) { width: 5%; } +.ipam-detail-table th:nth-child(2) { width: 12%; } +.ipam-detail-table th:nth-child(3) { width: 13%; } +.ipam-detail-table th:nth-child(4) { width: 14%; } +.ipam-detail-table th:nth-child(5) { width: 10%; } +.ipam-detail-table th:nth-child(6) { width: 17%; } +.ipam-detail-table th:nth-child(7) { width: 12%; } +.ipam-detail-table th:nth-child(8) { width: 17%; } -.ipam-detail-table th:nth-child(1) { width: 14%; } -.ipam-detail-table th:nth-child(2) { width: 18%; } -.ipam-detail-table th:nth-child(3) { width: 18%; } -.ipam-detail-table th:nth-child(4) { width: 13%; } -.ipam-detail-table th:nth-child(5) { width: 15%; } -.ipam-detail-table th:nth-child(6) { width: 22%; } +.ipam-index { + text-align: center; + color: var(--ipam-muted); + font-variant-numeric: tabular-nums; +} .ip-address { font-size: 14px; @@ -123,6 +144,117 @@ gap: 10px; } +.ipam-pagination-info, +.ipam-page-size, +.ipam-page-size label { + display: flex; + align-items: center; + gap: 10px; +} + +.ipam-page-size select { + min-width: 76px; + height: 34px; + padding: 5px 26px 5px 8px; + border: 1px solid #b8c4cc; + border-radius: 5px; + background: #fff; +} + +/* Element Plus semantic colors for buttons outside table action cells. */ +.ipam .btn-alt, +.ipam-modal .btn-alt { + border-color: #409EFF; + background: #409EFF; + color: #fff; +} + +.ipam .btn-alt:hover, +.ipam-modal .btn-alt:hover { + border-color: #337ECC; + background: #337ECC; + color: #fff; +} + +/* Keep every button inside table action cells on its existing palette. */ +.ipam .ipam-actions .btn-alt { + border-color: #AEBBC5; + background: #fff; + color: #253746; +} + +.ipam .ipam-actions .btn-alt:hover { + border-color: #909399; + background: #F4F4F5; + color: #253746; +} + +.ipam .js-add, +.ipam-modal .js-save-range { + border-color: #67C23A; + background: #67C23A; +} + +.ipam .js-add:hover, +.ipam-modal .js-save-range:hover { + border-color: #529B2E; + background: #529B2E; +} + +.ipam-modal [data-close] { + border-color: #909399; + background: #909399; + color: #fff; +} + +.ipam-modal [data-close]:hover { + border-color: #73767A; + background: #73767A; + color: #fff; +} + +.ipam .btn-alt.ipam-close { + min-width: 34px; + min-height: 34px; + padding: 2px 8px; + border: 1px solid #aebbc5; + border-radius: 5px; + background: #fff; + font-size: 20px; +} + +.ipam .btn-alt.btn-delete { + border-color: #F56C6C; + background: #F56C6C; + color: #fff; +} + +.ipam .btn-alt.btn-scan { + border-color: #67C23A; + background: #67C23A; + color: #fff; +} + +.ipam .btn-alt.btn-task { + border-color: #409EFF; + background: #409EFF; + color: #fff; +} + +.ipam .btn-alt.btn-edit { + border-color: #E6A23C; + background: #E6A23C; + color: #fff; +} + +.ipam .btn-alt.btn-delete:hover, +.ipam .btn-alt.btn-scan:hover, +.ipam .btn-alt.btn-task:hover, +.ipam .btn-alt.btn-edit:hover { + color: #fff; + filter: brightness(.94); +} + .ipam-actions { display: flex; flex-wrap: wrap; @@ -139,6 +271,84 @@ color: #fff !important; } +/* Size dialogs relative to the viewport and prevent form content from widening them. */ +.ipam-modal *, +.ipam-modal *::before, +.ipam-modal *::after { + box-sizing: border-box; +} + +.ipam-dialog { + width: 40vw; + max-width: calc(100vw - 48px); + overflow-x: hidden; + overflow-y: auto; +} + +.ipam-dialog.ipam-dialog-wide { + width: 80vw; +} + +.ipam-dialog > header, +.ipam-dialog > form, +.ipam-dialog > .ipam-legend, +.ipam-dialog > .ipam-grid { + width: 100%; + min-width: 0; + max-width: 100%; +} + +.ipam-dialog form { + grid-template-columns: minmax(0, 1fr); +} + +.ipam-form-note { + display: grid; + gap: 5px; + padding: 12px 14px; + border-left: 4px solid #409EFF; + border-radius: 5px; + background: #ECF5FF; + color: #606266; + line-height: 1.5; +} + +.ipam-form-note strong { + color: #303133; +} + +.ipam-dialog footer { + width: 100%; + min-width: 0; + max-width: 100%; +} + +.ipam-dialog form, +.ipam-dialog form > label, +.ipam-dialog form input:not([type="checkbox"]), +.ipam-dialog form select, +.ipam-dialog form textarea, +.ipam-switch-row > span { + min-width: 0; + max-width: 100%; +} + +.ipam-dialog form input:not([type="checkbox"]), +.ipam-dialog form select, +.ipam-dialog form textarea { + width: 100%; +} + +.ipam-dialog .ipam-switch-row input[type="checkbox"] { + flex: 0 0 20px; + width: 20px; +} + +.ipam-switch-row small { + white-space: normal; + overflow-wrap: anywhere; +} + @media (max-width: 760px) { .ipam { width: calc(100vw - 16px); @@ -148,6 +358,10 @@ min-width: 680px; } + .ipam-range-table { + min-width: 1050px; + } + .ipam-detail-table, .ipam-task-table { min-width: 920px; @@ -157,4 +371,15 @@ align-items: flex-start; flex-direction: column; } + + .ipam-pagination-info { + align-items: flex-start; + flex-direction: column; + } + + .ipam-dialog, + .ipam-dialog.ipam-dialog-wide { + width: calc(100vw - 24px); + max-width: calc(100vw - 24px); + } } diff --git a/zabbix_ipam/assets/js/ipam.js.php b/zabbix_ipam/assets/js/ipam.js.php index f9e0273..fd6656f 100644 --- a/zabbix_ipam/assets/js/ipam.js.php +++ b/zabbix_ipam/assets/js/ipam.js.php @@ -60,7 +60,6 @@ const form = document.getElementById('range-form'); form.reset(); form.elements.id.value = ''; - form.elements.scan_interval.value = '60'; form.elements.enabled.checked = true; document.getElementById('range-modal-title').textContent = event.target.closest('.js-add').textContent; open('range-modal'); @@ -74,7 +73,6 @@ form.elements.id.value = data.id; form.elements.name.value = data.name; form.elements.range.value = data.range; - form.elements.scan_interval.value = data.scan_interval; form.elements.enabled.checked = !!data.enabled; document.getElementById('range-modal-title').textContent = edit.textContent; open('range-modal'); @@ -144,6 +142,7 @@ const count = event.target.closest('.ipam-count'); if (count) { + event.preventDefault(); const grid = document.getElementById('ip-grid'); grid.innerHTML = '
'+(root.dataset.loading || 'Loading...')+'
'; open('matrix-modal'); @@ -200,6 +199,20 @@ }); } + document.querySelectorAll('.js-auto-filter').forEach(form => { + form.querySelectorAll('select').forEach(select => { + select.addEventListener('change', () => form.submit()); + }); + form.querySelectorAll('input[name="search"],input[type="search"]').forEach(input => { + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { + event.preventDefault(); + form.submit(); + } + }); + }); + }); + const active_rows = [...document.querySelectorAll('[data-task-row]')] .filter(row => row.querySelector('.status-running,.status-pending')); diff --git a/zabbix_ipam/cli/scan_cron.php b/zabbix_ipam/cli/scan_cron.php index 00e4d97..f91bd68 100644 --- a/zabbix_ipam/cli/scan_cron.php +++ b/zabbix_ipam/cli/scan_cron.php @@ -5,4 +5,4 @@ use Modules\ZabbixIpam\Lib\{IpStorage,IpScanner,TaskManager}; $storage=new IpStorage();$manager=new TaskManager($storage);$taskId=null;foreach($argv as $arg)if(strpos($arg,'--task=')===0)$taskId=substr($arg,7); if($taskId!==null){if(!preg_match('/^[a-f0-9]{24}$/',$taskId)){fwrite(STDERR,"Invalid task ID\n");exit(2);}$lock=fopen($storage->root().'/scan_tasks/'.$taskId.'.worker.lock','c+');if(!$lock||!flock($lock,LOCK_EX|LOCK_NB)){fwrite(STDERR,"Task worker already running\n");exit(0);}$task=$storage->task($taskId);if(!$task||($task['status']??'')!=='pending'){flock($lock,LOCK_UN);fclose($lock);exit(0);}(new IpScanner($storage))->run($taskId);flock($lock,LOCK_UN);fclose($lock);exit(0);} -foreach($manager->due() as $range){try{$task=$manager->start($range['id']);if(($task['status']??'')==='pending'&&empty($task['started_at']))(new IpScanner($storage))->run($task['id']);}catch(\Throwable $e){fwrite(STDERR,'IPAM: '.$e->getMessage()."\n");}} +foreach($manager->due() as $range){try{$task=$manager->start($range['id']);if(($task['status']??'')==='pending'&&empty($task['started_at'])&&empty($task['dispatched_at']))(new IpScanner($storage))->run($task['id']);}catch(\Throwable $e){fwrite(STDERR,'IPAM: '.$e->getMessage()."\n");}} diff --git a/zabbix_ipam/images/1.png b/zabbix_ipam/images/1.png new file mode 100644 index 0000000..a921244 Binary files /dev/null and b/zabbix_ipam/images/1.png differ diff --git a/zabbix_ipam/images/2.png b/zabbix_ipam/images/2.png new file mode 100644 index 0000000..571f8bd Binary files /dev/null and b/zabbix_ipam/images/2.png differ diff --git a/zabbix_ipam/images/3.png b/zabbix_ipam/images/3.png new file mode 100644 index 0000000..99b1ce6 Binary files /dev/null and b/zabbix_ipam/images/3.png differ diff --git a/zabbix_ipam/lib/HostMatcher.php b/zabbix_ipam/lib/HostMatcher.php index d89ac47..c331f6a 100644 --- a/zabbix_ipam/lib/HostMatcher.php +++ b/zabbix_ipam/lib/HostMatcher.php @@ -50,6 +50,9 @@ public static function byIp(): array { } public static function hostUrl(string $host_id): string { - return 'zabbix.php?action=host.edit&hostid='.rawurlencode($host_id); + return 'zabbix.php?name=&evaltype=0&tags%5B0%5D%5Btag%5D=&tags%5B0%5D%5Boperator%5D=0' + .'&tags%5B0%5D%5Bvalue%5D=&show_tags=3&tag_name_format=0&tag_priority=&state=-1' + .'&filter_name=&filter_show_counter=0&filter_custom_time=0&sort=name&sortorder=ASC' + .'&show_details=0&action=latest.view&hostids%5B%5D='.rawurlencode($host_id); } } diff --git a/zabbix_ipam/lib/LanguageManager.php b/zabbix_ipam/lib/LanguageManager.php index 4f1e1ad..7023620 100644 --- a/zabbix_ipam/lib/LanguageManager.php +++ b/zabbix_ipam/lib/LanguageManager.php @@ -16,14 +16,17 @@ class LanguageManager { 'Quick guide' => '使用说明', 'Add a range, start an ICMP scan, then click the IP count to view the availability matrix. Green means alive; gray means unreachable or not scanned.' => '添加 IP 段后启动 ICMP 扫描;点击 IP 数量可查看使用矩阵。绿色表示存活,灰色表示不可达或尚未扫描。', 'Association rules' => '关联说明', - 'A host is associated automatically when one of its Zabbix interfaces uses the same IPv4 address. Click the host name to open its Zabbix configuration page.' => '当 Zabbix 主机接口使用相同 IPv4 地址时自动建立关联。点击主机名称可打开 Zabbix 主机详情页。', + 'A host is associated automatically when one of its Zabbix interfaces uses the same IPv4 address. Click the host name to open its latest data page.' => '当 Zabbix 主机接口使用相同 IPv4 地址时自动建立关联。点击主机名称可打开该主机的最新数据页面。', 'Add IP range' => '添加 IP 段', 'Edit IP range' => '修改 IP 段', 'All IP ranges' => '全部 IP 段', 'IP' => 'IP', 'IP range' => 'IP 段', 'IP range name' => 'IP 段名称', + 'No.' => '序号', + 'Task ID' => '任务 ID', 'IP status' => 'IP 状态', + 'Status last updated' => '状态最后更新时间', 'Name' => '名称', 'Example: Office network' => '例如:办公网络', 'Search' => '搜索', @@ -31,9 +34,14 @@ class LanguageManager { 'Search task ID or range name' => '搜索任务 ID 或 IP 段名称', 'Search IP, range, or host name' => '搜索 IP、IP 段或主机名称', 'Status' => '状态', + 'Enabled status' => '启用状态', + 'Scan status' => '扫描状态', + 'Last scan time' => '最后扫描时间', 'Filter' => '筛选', 'Reset' => '重置', 'All statuses' => '全部状态', + 'All enabled statuses' => '全部启用状态', + 'All scan statuses' => '全部扫描状态', 'All IP statuses' => '全部 IP 状态', 'Enabled' => '已启用', 'Disabled' => '已停用', @@ -60,9 +68,10 @@ class LanguageManager { 'Delete' => '删除', 'No IP ranges found.' => '没有符合条件的 IP 段。', 'No IP addresses found.' => '没有符合条件的 IP。', - 'Scan interval (minutes)' => '扫描间隔(分钟)', + 'Scan schedule' => '扫描计划', + 'The scan interval is controlled by crontab. Each enabled IP range is scanned whenever the cron job runs.' => '扫描间隔由 crontab 设置;每次 cron 任务运行时,所有已启用的 IP 段都会执行扫描。', 'Enable scheduled scans' => '启用定时扫描', - 'The cron entry scans this range when its interval is due.' => '到达扫描间隔后,由 cron 自动扫描此 IP 段。', + 'When enabled, this range is scanned each time the IPAM cron job runs.' => '启用后,每次 IPAM cron 任务运行时都会扫描此 IP 段。', 'Cancel' => '取消', 'Save' => '保存', 'Close' => '关闭', @@ -81,7 +90,9 @@ class LanguageManager { 'No scan tasks found.' => '没有符合条件的扫描任务。', 'Previous' => '上一页', 'Next' => '下一页', + 'Items per page' => '每页数量', 'Page %1$d of %2$d' => '第 %1$d / %2$d 页', + '%1$d items in total' => '共 %1$d 条', '%1$d IPs in total' => '共 %1$d 个 IP', 'Range saved.' => 'IP 段已保存。', 'Range deleted.' => 'IP 段已删除。', diff --git a/zabbix_ipam/lib/TaskManager.php b/zabbix_ipam/lib/TaskManager.php index 20be849..f0d51b2 100644 --- a/zabbix_ipam/lib/TaskManager.php +++ b/zabbix_ipam/lib/TaskManager.php @@ -21,5 +21,6 @@ public function dispatch(string $id): bool { private function findCli():?string{foreach(['/usr/bin/php','/usr/local/bin/php','/opt/remi/php83/root/usr/bin/php'] as $p)if(is_executable($p))return $p;if(PHP_SAPI==='cli'&&is_executable(PHP_BINARY)&&strpos(basename(PHP_BINARY),'fpm')===false)return PHP_BINARY;return null;} private function fail(string $id,string $message):void{$t=$this->storage->task($id);if(!$t)return;$t['status']='failed';$t['error']=$message;$t['updated_at']=gmdate('c');$this->storage->saveTask($t);} public function stop(string $id):void{$t=$this->storage->task($id);if(!$t)throw new \InvalidArgumentException('Task not found');if(in_array($t['status'],['pending','running'],true)){$t['status']='stopped';$t['updated_at']=gmdate('c');$this->storage->saveTask($t);}} - public function due():array{$out=[];$now=time();foreach($this->storage->ranges() as $r){if(empty($r['enabled']))continue;$last=strtotime($r['last_scan']??'')?:0;$interval=max(1,(int)($r['scan_interval']??60))*60;if($now-$last>=$interval)$out[]=$r;}return $out;} + /** Every cron invocation schedules all enabled ranges; the crontab expression defines the interval. */ + public function due():array{return array_values(array_filter($this->storage->ranges(),fn($r)=>!empty($r['enabled'])));} } diff --git a/zabbix_ipam/lib/TimeFormatter.php b/zabbix_ipam/lib/TimeFormatter.php new file mode 100644 index 0000000..a6d759c --- /dev/null +++ b/zabbix_ipam/lib/TimeFormatter.php @@ -0,0 +1,24 @@ + htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + $translate = [LanguageManager::class, 't']; + $base = array_merge(['action' => $action], $filters); + unset($base['page'], $base['per_page']); + + $hidden = ''; + foreach ($base as $name => $value) { + $hidden .= ''; + } + + $sizes = ''; + foreach ([20, 50, 100, 200] as $size) { + $selected = (int) $pagination['per_page'] === $size ? ' selected' : ''; + $sizes .= ''; + } + + $page_url = static function (int $page) use ($base, $pagination): string { + return 'zabbix.php?'.http_build_query(array_merge($base, [ + 'page' => $page, + 'per_page' => $pagination['per_page'] + ])); + }; + + $html = '
' + .'
'.$hidden.'' + .'
' + .''.sprintf($translate('%1$d items in total'), (int) $pagination['total']).'
'; + } +} diff --git a/zabbix_ipam/manifest.json b/zabbix_ipam/manifest.json index c840d90..866973c 100644 --- a/zabbix_ipam/manifest.json +++ b/zabbix_ipam/manifest.json @@ -1,10 +1,10 @@ { "manifest_version": 2.0, "id": "zabbix-ipam", - "name": "IPAM", - "author": "Zabbix IPAM contributors", + "name": "Zabbix IPAM", + "author": "火星小刘", "url": "https://github.com/X-Mars/zabbix_modules", - "version": "1.2.0", + "version": "1.5.0", "namespace": "ZabbixIpam", "description": "IP address management and asynchronous network discovery for Zabbix", "actions": { diff --git a/zabbix_ipam/views/ip.detail.php b/zabbix_ipam/views/ip.detail.php index 2d6859b..5103fb8 100644 --- a/zabbix_ipam/views/ip.detail.php +++ b/zabbix_ipam/views/ip.detail.php @@ -1,9 +1,13 @@ '.$e($host['name']).''; } + $status_updated_at = TimeFormatter::display($row['status_updated_at']); $rows .= '' + .''.$row_number++.'' .''.$e($row['ip']).'' .''.$e($row['range_name']).'' .''.$e($row['range']).'' .''.$t($row['state'] === 'alive' ? 'Alive' : ($row['state'] === 'down' ? 'Unreachable' : 'Not scanned')).'' + .''.($status_updated_at !== '' ? '' : ''.$t('Never').'').'' .''.$t($row['associated'] ? 'Associated' : 'Unassociated').'' .''.($host_links ? implode('
', $host_links) : ''.$t('No associated host').'').'' .''; } $filters = $data['filters']; -$page_url = static function (int $page) use ($filters): string { - return 'zabbix.php?'.http_build_query([ - 'action' => 'ip.detail', - 'search' => $filters['search'], - 'rangeid' => $filters['rangeid'], - 'status' => $filters['status'], - 'associated' => $filters['associated'], - 'page' => $page - ]); -}; +$pagination = ViewPagination::render('ip.detail', $data['pagination'], $filters); -$pagination = '
'.sprintf($t('%1$d IPs in total'), $data['pagination']['total']).'
'; - -$html = '' +$html = '' .'

'.$t('IP Details').'

'.$t('Browse every address, its latest ICMP status, and its matched Zabbix host.').'

' - .'
' - .'
'.$t('Association rules').''.$t('A host is associated automatically when one of its Zabbix interfaces uses the same IPv4 address. Click the host name to open its Zabbix configuration page.').'
' - .'
' + .'' + .'
'.$t('Association rules').''.$t('A host is associated automatically when one of its Zabbix interfaces uses the same IPv4 address. Click the host name to open its latest data page.').'
' + .'' .'' .'' .'' - .'' - .''.$t('Reset').'
' + .'' .'
' - .'' - .''.($rows ?: '').'
'.$t('IP').''.$t('IP range name').''.$t('IP range').''.$t('IP status').''.$t('Host association').''.$t('Associated host').'
'.$t('No IP addresses found.').'
'.$pagination.'
'; + .''.$t('No.').''.$t('IP').''.$t('IP range name').''.$t('IP range').''.$t('IP status').''.$t('Status last updated').''.$t('Host association').''.$t('Associated host').'' + .''.($rows ?: ''.$t('No IP addresses found.').'').''.$pagination.'' + .''; ViewRenderer::render($data['title'], $html); diff --git a/zabbix_ipam/views/ip.manager.php b/zabbix_ipam/views/ip.manager.php index 66c0b2c..625bf2f 100644 --- a/zabbix_ipam/views/ip.manager.php +++ b/zabbix_ipam/views/ip.manager.php @@ -2,8 +2,12 @@ require_once dirname(__DIR__).'/lib/ViewRenderer.php'; require_once dirname(__DIR__).'/lib/LanguageManager.php'; +require_once dirname(__DIR__).'/lib/TimeFormatter.php'; +require_once dirname(__DIR__).'/lib/ViewPagination.php'; use Modules\ZabbixIpam\Lib\LanguageManager; +use Modules\ZabbixIpam\Lib\TimeFormatter; +use Modules\ZabbixIpam\Lib\ViewPagination; use Modules\ZabbixIpam\Lib\ViewRenderer; $t = [LanguageManager::class, 't']; @@ -15,13 +19,20 @@ $range_options .= ''; } -$status_options = ''; -foreach (['all' => 'All statuses', 'enabled' => 'Enabled', 'disabled' => 'Disabled', 'pending' => 'Pending', 'running' => 'Running', 'completed' => 'Completed', 'failed' => 'Failed'] as $value => $label) { - $selected = $data['filters']['status'] === $value ? ' selected' : ''; - $status_options .= ''; +$enabled_options = ''; +foreach (['all' => 'All enabled statuses', 'enabled' => 'Enabled', 'disabled' => 'Disabled'] as $value => $label) { + $selected = $data['filters']['enabled_status'] === $value ? ' selected' : ''; + $enabled_options .= ''; +} + +$scan_options = ''; +foreach (['all' => 'All scan statuses', 'never' => 'Never', 'pending' => 'Pending', 'running' => 'Running', 'completed' => 'Completed', 'failed' => 'Failed', 'stopped' => 'Stopped'] as $value => $label) { + $selected = $data['filters']['scan_status'] === $value ? ' selected' : ''; + $scan_options .= ''; } $rows = ''; +$row_number = (($data['pagination']['page'] - 1) * $data['pagination']['per_page']) + 1; foreach ($data['rows'] as $row) { $range = $row['range']; $task = $row['task']; @@ -30,47 +41,52 @@ 'id' => $range['id'], 'name' => $range['name'], 'range' => $range['range'], - 'scan_interval' => (int) ($range['scan_interval'] ?? 60), 'enabled' => !empty($range['enabled']) ]; $task_url = $task ? 'zabbix.php?action=ip.scan&taskid='.$e($task['id']) : 'zabbix.php?action=ip.scan&search='.rawurlencode((string) $range['name']); - $rows .= '
'.$e($range['range']).'
' - .'
'.$e($range['name']).' '.$t(!empty($range['enabled']) ? 'Enabled' : 'Disabled').'
' - .'' - .'
'.$t(ucfirst($status)).'
' - .'' - .''.$t('View task').'' - .'' - .''; + $last_scan = TimeFormatter::display($row['last_scan']); + $rows .= ''.$row_number++.'' + .''.$e($range['name']).'' + .'
'.$e($range['range']).'
' + .''.(int) $row['alive'].'/'.(int) $row['total'].'' + .''.$t(!empty($range['enabled']) ? 'Enabled' : 'Disabled').'' + .''.$t(ucfirst($status)).'' + .''.($last_scan !== '' ? '' : ''.$t('Never').'').'' + .'' + .''.$t('View task').'' + .'' + .''; } -$html = '' +$pagination = ViewPagination::render('ip.manager', $data['pagination'], $data['filters']); + +$html = '' .'
' .'

'.$t('IPAM').'

'.$t('Manage IPv4 ranges and run ICMP-only availability scans.').'

' - .'
' + .'
'.$t('IP Details').''.$t('Task Management').'
' .'
'.$t('Quick guide').''.$t('Add a range, start an ICMP scan, then click the IP count to view the availability matrix. Green means alive; gray means unreachable or not scanned.').'
' - .'
' + .'' .'' .'' - .'' - .''.$t('Reset').'
' - .'
' - .''.($rows ?: '').'
'.$t('IP range').''.$t('Alive IPs / Total').''.$t('Actions').'
'.$t('No IP ranges found.').'
'; + .'' + .'' + .'
' + .''.($rows ?: '').'
'.$t('No.').''.$t('IP range name').''.$t('IP range').''.$t('Alive IPs / Total').''.$t('Enabled status').''.$t('Scan status').''.$t('Last scan time').''.$t('Actions').'
'.$t('No IP ranges found.').'
'.$pagination.''; $html .= ''; + .'
'.$t('Scan schedule').''.$t('The scan interval is controlled by crontab. Each enabled IP range is scanned whenever the cron job runs.').'
' + .'' + .''; $html .= '' - .''; + .''; ViewRenderer::render($data['title'], $html); diff --git a/zabbix_ipam/views/ip.scan.php b/zabbix_ipam/views/ip.scan.php index ae2c4b1..4969fd5 100644 --- a/zabbix_ipam/views/ip.scan.php +++ b/zabbix_ipam/views/ip.scan.php @@ -2,19 +2,18 @@ require_once dirname(__DIR__).'/lib/ViewRenderer.php'; require_once dirname(__DIR__).'/lib/LanguageManager.php'; +require_once dirname(__DIR__).'/lib/TimeFormatter.php'; +require_once dirname(__DIR__).'/lib/ViewPagination.php'; use Modules\ZabbixIpam\Lib\LanguageManager; +use Modules\ZabbixIpam\Lib\TimeFormatter; +use Modules\ZabbixIpam\Lib\ViewPagination; use Modules\ZabbixIpam\Lib\ViewRenderer; $t = [LanguageManager::class, 't']; $e = static fn($value): string => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); -$counts = ['pending' => 0, 'running' => 0, 'completed' => 0, 'failed' => 0]; -foreach ($data['tasks'] as $task) { - if (isset($counts[$task['status']])) { - $counts[$task['status']]++; - } -} +$counts = $data['summary']; $status_options = ''; foreach (['all' => 'All statuses', 'pending' => 'Pending', 'running' => 'Running', 'completed' => 'Completed', 'failed' => 'Failed', 'stopped' => 'Stopped'] as $value => $label) { @@ -23,19 +22,23 @@ } $rows = ''; +$row_number = (($data['pagination']['page'] - 1) * $data['pagination']['per_page']) + 1; foreach ($data['tasks'] as $task) { $percent = $task['total'] ? min(100, round($task['scanned'] / $task['total'] * 100)) : 0; $action = in_array($task['status'], ['pending', 'running'], true) - ? '' - : ''.$t('View range').''; + ? '' + : ''.$t('View range').''; + $created_at = TimeFormatter::display($task['created_at'] ?? ''); $rows .= '' - .''.$e($task['id']).'
'.$e($task['range_name']).'
' + .''.$row_number++.'' + .''.$e($task['id']).'' + .''.$e($task['range_name']).'' .''.$t(ucfirst($task['status'])).'' .'
'.(int) $task['scanned'].' / '.(int) $task['total'].' · '.$percent.'%' .''.(int) $task['alive'].'' .''.(int) $task['completed_shards'].' / '.count($task['shards']).'' - .''.$e($task['created_at']).'' + .''.($created_at !== '' ? '' : ''.$t('Never').'').'' .''.$action.''; } @@ -47,16 +50,22 @@ .'data-label-failed="'.$e($t('Failed')).'" ' .'data-label-stopped="'.$e($t('Stopped')).'"'; -$html = '' +$pagination_filters = $data['filters']; +if ($data['selected'] !== '') { + $pagination_filters['taskid'] = $data['selected']; +} +$pagination = ViewPagination::render('ip.scan', $data['pagination'], $pagination_filters); + +$html = '' .'

'.$t('Task Management').'

'.$t('Track ICMP scan progress and review completed background tasks.').'

' - .'
' - .'
'.count($data['tasks']).''.$t('Tasks').'
' + .'' + .'
'.$data['pagination']['total'].''.$t('Tasks').'
' .'
'.$counts['running'].''.$t('Running').'
'.$counts['completed'].''.$t('Completed').'
'.$counts['failed'].''.$t('Failed').'
' - .'
' + .'' .'' - .''.$t('Reset').'
' - .'
' - .''.($rows ?: '').'
'.$t('Task / IP range').''.$t('Status').''.$t('Progress').''.$t('Alive').''.$t('Shards').''.$t('Created').''.$t('Actions').'
'.$t('No scan tasks found.').'
' - .''; + .'' + .'
' + .''.($rows ?: '').'
'.$t('No.').''.$t('Task ID').''.$t('IP range name').''.$t('Status').''.$t('Progress').''.$t('Alive').''.$t('Shards').''.$t('Created').''.$t('Actions').'
'.$t('No scan tasks found.').'
'.$pagination.'
' + .''; ViewRenderer::render($data['title'], $html);