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 请求。
+
+
+
+
+
+## 功能特性
+
+- **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 .= ''.$size.' ';
+ }
+
+ $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 = '';
+ }
+}
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 !== '' ? ''.$e($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 = '';
-
-$html = ' '
+$html = ' '
.''
- .'
'.$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('Host association').' '.$association_options.' '
.'
'
- .''.$t('IP').' '.$t('IP range name').' '.$t('IP range').' '.$t('IP status').' '.$t('Host association').' '.$t('Associated host').' '
- .' '.($rows ?: ''.$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 .= ''.$e($range['name'].' · '.$range['range']).' ';
}
-$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 .= ''.$t($label).' ';
+$enabled_options = '';
+foreach (['all' => 'All enabled statuses', 'enabled' => 'Enabled', 'disabled' => 'Disabled'] as $value => $label) {
+ $selected = $data['filters']['enabled_status'] === $value ? ' selected' : '';
+ $enabled_options .= ''.$t($label).' ';
+}
+
+$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 .= ''.$t($label).' ';
}
$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').'
'
- .''.(int) $row['alive'].' / '.(int) $row['total'].' '
- .''.$t(ucfirst($status)).'
'
- .''.$t('Start scan').' '
- .''.$t('View task').' '
- .''.$t('Edit').' '
- .''.$t('Delete').' ';
+ $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 !== '' ? ''.$e($last_scan).' ' : ''.$t('Never').' ').' '
+ .''.$t('Start scan').' '
+ .''.$t('View task').' '
+ .''.$t('Edit').' '
+ .''.$t('Delete').' ';
}
-$html = ' '
+$pagination = ViewPagination::render('ip.manager', $data['pagination'], $data['filters']);
+
+$html = ' '
.''
.'
'
+ .'
'
.'
'.$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('IP range').' '.$t('Alive IPs / Total').' '.$t('Actions').' '
- .''.($rows ?: ''.$t('No IP ranges found.').' ').'
';
+ .''.$t('Enabled status').' '.$enabled_options.' '
+ .''.$t('Scan status').' '.$scan_options.' '
+ .''.$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').' '
+ .''.($rows ?: ''.$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.').'
'
+ .''.$t('Enable scheduled scans').' '.$t('When enabled, this range is scanned each time the IPAM cron job runs.').' '
+ .''.$t('Cancel').' '.$t('Save').' ';
$html .= '
'
- .''.$t('IP availability matrix').'
× '
+ .''.$t('IP availability matrix').'
× '
.' '.$t('Alive').' '.$t('Unreachable / not scanned').'
'
- .'
';
+ .'
';
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('Stop').' '
- : ''.$t('View range').' ';
+ ? ''.$t('Stop').' '
+ : ''.$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 !== '' ? ''.$e($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 = ' '
.''
- .'
'.count($data['tasks']).' '.$t('Tasks').'
'
+ .'
'
+ .'
'.$data['pagination']['total'].' '.$t('Tasks').'
'
.'
'.$counts['running'].' '.$t('Running').'
'.$counts['completed'].' '.$t('Completed').'
'.$counts['failed'].' '.$t('Failed').'
'
- .'
'
- .'
'.$t('Task / IP range').' '.$t('Status').' '.$t('Progress').' '.$t('Alive').' '.$t('Shards').' '.$t('Created').' '.$t('Actions').' '
- .''.($rows ?: ''.$t('No scan tasks found.').' ').'
'
- .'
';
+ .'
'.$t('Status').' '.$status_options.' '
+ .'
'.$t('No.').' '.$t('Task ID').' '.$t('IP range name').' '.$t('Status').' '.$t('Progress').' '.$t('Alive').' '.$t('Shards').' '.$t('Created').' '.$t('Actions').' '
+ .''.($rows ?: ''.$t('No scan tasks found.').' ').'
'.$pagination.'
'
+ .'
';
ViewRenderer::render($data['title'], $html);