proxsync 2.0.2__tar.gz → 2.0.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {proxsync-2.0.2/proxsync.egg-info → proxsync-2.0.4}/PKG-INFO +1 -1
  2. {proxsync-2.0.2 → proxsync-2.0.4/proxsync.egg-info}/PKG-INFO +1 -1
  3. {proxsync-2.0.2 → proxsync-2.0.4}/proxsync.egg-info/SOURCES.txt +4 -0
  4. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync.py +149 -1
  5. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/api/serializers.py +17 -0
  6. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/api/urls.py +2 -1
  7. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/api/views.py +7 -0
  8. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/choices.py +13 -0
  9. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/filtersets.py +25 -0
  10. proxsync-2.0.4/pve_sync_plugin/migrations/0015_pvevmtasklog.py +55 -0
  11. proxsync-2.0.4/pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py +18 -0
  12. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/models.py +57 -0
  13. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/navigation.py +6 -1
  14. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/tables.py +25 -0
  15. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/template_content.py +35 -2
  16. proxsync-2.0.4/pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html +57 -0
  17. proxsync-2.0.4/pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html +10 -0
  18. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/urls.py +8 -1
  19. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/views.py +29 -7
  20. {proxsync-2.0.2 → proxsync-2.0.4}/pyproject.toml +1 -1
  21. {proxsync-2.0.2 → proxsync-2.0.4}/MANIFEST.in +0 -0
  22. {proxsync-2.0.2 → proxsync-2.0.4}/README.md +0 -0
  23. {proxsync-2.0.2 → proxsync-2.0.4}/config.py +0 -0
  24. {proxsync-2.0.2 → proxsync-2.0.4}/pbs_sync.py +0 -0
  25. {proxsync-2.0.2 → proxsync-2.0.4}/proxsync.egg-info/dependency_links.txt +0 -0
  26. {proxsync-2.0.2 → proxsync-2.0.4}/proxsync.egg-info/requires.txt +0 -0
  27. {proxsync-2.0.2 → proxsync-2.0.4}/proxsync.egg-info/top_level.txt +0 -0
  28. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/__init__.py +0 -0
  29. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/api/__init__.py +0 -0
  30. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/apps.py +0 -0
  31. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/forms.py +0 -0
  32. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/management/__init__.py +0 -0
  33. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/management/commands/__init__.py +0 -0
  34. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  35. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  36. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  37. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  38. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  39. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  40. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  41. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  42. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  43. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  44. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  45. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  46. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  47. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  48. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  49. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +0 -0
  50. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/migrations/__init__.py +0 -0
  51. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/signals.py +0 -0
  52. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/sync/__init__.py +0 -0
  53. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/sync/config_bridge.py +0 -0
  54. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/sync/engine.py +0 -0
  55. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/tasks.py +0 -0
  56. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  57. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  58. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  59. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  60. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  61. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  62. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +0 -0
  63. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  64. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  65. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +0 -0
  66. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +0 -0
  67. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templatetags/__init__.py +0 -0
  68. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/templatetags/pve_sync_tags.py +0 -0
  69. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/utils.py +0 -0
  70. {proxsync-2.0.2 → proxsync-2.0.4}/pve_sync_plugin/view_helpers.py +0 -0
  71. {proxsync-2.0.2 → proxsync-2.0.4}/setup.cfg +0 -0
  72. {proxsync-2.0.2 → proxsync-2.0.4}/state_db.py +0 -0
  73. {proxsync-2.0.2 → proxsync-2.0.4}/tests/test_plugin_contracts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxsync
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxsync
3
- Version: 2.0.2
3
+ Version: 2.0.4
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -47,6 +47,8 @@ pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py
47
47
  pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py
48
48
  pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py
49
49
  pve_sync_plugin/migrations/0014_vmprovisioninglog.py
50
+ pve_sync_plugin/migrations/0015_pvevmtasklog.py
51
+ pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py
50
52
  pve_sync_plugin/migrations/__init__.py
51
53
  pve_sync_plugin/sync/__init__.py
52
54
  pve_sync_plugin/sync/config_bridge.py
@@ -57,11 +59,13 @@ pve_sync_plugin/templates/pve_sync/pvebackupstatus.html
57
59
  pve_sync_plugin/templates/pve_sync/pveclusterconfig.html
58
60
  pve_sync_plugin/templates/pve_sync/pvedriftevent.html
59
61
  pve_sync_plugin/templates/pve_sync/pvesyncjob.html
62
+ pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html
60
63
  pve_sync_plugin/templates/pve_sync/pvewebhookevent.html
61
64
  pve_sync_plugin/templates/pve_sync/settings.html
62
65
  pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html
63
66
  pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html
64
67
  pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html
68
+ pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html
65
69
  pve_sync_plugin/templatetags/__init__.py
66
70
  pve_sync_plugin/templatetags/pve_sync_tags.py
67
71
  tests/test_plugin_contracts.py
@@ -1235,6 +1235,145 @@ class OptimizedPVEToNetBoxSync:
1235
1235
  print(f"⚠️ 清理孤兒 VM 失敗: {exc}")
1236
1236
  traceback.print_exc()
1237
1237
 
1238
+ def _apply_log_retention(self):
1239
+ """依 PvePluginSettings.log_retention_days 清理過期記錄。"""
1240
+ try:
1241
+ import datetime as _dt
1242
+ from django.utils import timezone
1243
+ from pve_sync_plugin.models import PveDriftEvent, PvePluginSettings, PveVmTaskLog
1244
+ days = PvePluginSettings.load().log_retention_days
1245
+ if not days:
1246
+ return
1247
+ cutoff = timezone.now() - _dt.timedelta(days=days)
1248
+ d_del, _ = PveDriftEvent.objects.filter(created__lt=cutoff).delete()
1249
+ t_del, _ = PveVmTaskLog.objects.filter(start_time__lt=cutoff).delete()
1250
+ if d_del or t_del:
1251
+ print(f" 🧹 Log 清理:刪除 {d_del} 筆漂移事件、{t_del} 筆操作記錄(>{days} 天)")
1252
+ except Exception as exc:
1253
+ print(f" ⚠️ Log 清理失敗: {exc}")
1254
+
1255
+ def sync_vm_tasks(self):
1256
+ """從 PVE 各節點抓取 VM 操作記錄(建立/刪除/Clone/遷移)並同步到 NetBox。"""
1257
+ try:
1258
+ from django.utils import timezone
1259
+ from pve_sync_plugin.models import PveVmTaskLog
1260
+ except ImportError:
1261
+ return
1262
+
1263
+ VM_TASK_TYPES = {
1264
+ "qmcreate", "qmdestroy", "qmclone", "qmmigrate", "qmrestore",
1265
+ "qmstart", "qmstop", "qmshutdown", "qmreboot",
1266
+ }
1267
+ NOTIFY_TYPES = {"qmdestroy", "qmclone", "qmrestore", "qmshutdown", "qmstop"}
1268
+ TASK_LABELS = {
1269
+ "qmcreate": "🆕 VM 建立",
1270
+ "qmdestroy": "🗑️ VM 刪除",
1271
+ "qmclone": "📋 VM Clone",
1272
+ "qmmigrate": "🔀 VM 遷移",
1273
+ "qmrestore": "♻️ VM 還原",
1274
+ "qmstart": "▶️ VM 開機",
1275
+ "qmstop": "⏹️ VM 強制關機",
1276
+ "qmshutdown": "🔽 VM 正常關機",
1277
+ "qmreboot": "🔄 VM 重新開機",
1278
+ }
1279
+
1280
+ # 從 NetBox cache 建立 vmid → vm_name 對照
1281
+ vmid_to_name: dict = {}
1282
+ for key, vm in self.nb_cache.get("virtual_machines_by_serial", {}).items():
1283
+ try:
1284
+ vmid_to_name[int(vm.serial)] = vm.name
1285
+ except (ValueError, TypeError, AttributeError):
1286
+ pass
1287
+
1288
+ # 只通知最近 2 小時內的操作(避免首次匯入時大量通知)
1289
+ notify_cutoff = time.time() - 7200
1290
+
1291
+ # 從上次已存記錄的最新時間起算(+1 秒避免邊界重複);首次則抓 30 天
1292
+ latest = PveVmTaskLog.objects.filter(
1293
+ cluster_name=self.cluster_name
1294
+ ).order_by("-start_time").values_list("start_time", flat=True).first()
1295
+ if latest:
1296
+ import calendar
1297
+ since_ts = int(calendar.timegm(latest.utctimetuple())) + 1
1298
+ else:
1299
+ since_ts = int(time.time()) - 30 * 86400
1300
+ new_count = 0
1301
+
1302
+ for node_name in self.pve_cache.get("vms_by_node", {}):
1303
+ try:
1304
+ tasks = self.pve_api.nodes(node_name).tasks.get(since=since_ts, limit=1000)
1305
+ except Exception as exc:
1306
+ print(f" ⚠️ 無法取得節點 {node_name} 的操作記錄: {exc}")
1307
+ continue
1308
+
1309
+ for task in tasks:
1310
+ if task.get("type") not in VM_TASK_TYPES:
1311
+ continue
1312
+
1313
+ upid = task.get("upid", "")
1314
+ if not upid:
1315
+ continue
1316
+
1317
+ vmid_str = task.get("id", "")
1318
+ try:
1319
+ vmid = int(vmid_str)
1320
+ except (ValueError, TypeError):
1321
+ continue
1322
+
1323
+ # UPID 去重
1324
+ if PveVmTaskLog.objects.filter(upid=upid).exists():
1325
+ continue
1326
+
1327
+ task_type = task.get("type")
1328
+ operator = task.get("user", "unknown")
1329
+ start_ts = task.get("starttime")
1330
+ end_ts = task.get("endtime")
1331
+ status = task.get("status", "") or ""
1332
+ vm_name = vmid_to_name.get(vmid, f"VM-{vmid}")
1333
+
1334
+ import datetime as _dt
1335
+ _utc = _dt.timezone.utc
1336
+ start_dt = _dt.datetime.fromtimestamp(start_ts, tz=_utc) if start_ts else timezone.now()
1337
+ end_dt = _dt.datetime.fromtimestamp(end_ts, tz=_utc) if end_ts else None
1338
+
1339
+ log = PveVmTaskLog.objects.create(
1340
+ upid=upid,
1341
+ vmid=vmid,
1342
+ vm_name=vm_name,
1343
+ cluster_name=self.cluster_name,
1344
+ node=node_name,
1345
+ task_type=task_type,
1346
+ operator=operator,
1347
+ start_time=start_dt,
1348
+ end_time=end_dt,
1349
+ status=status,
1350
+ notified_telegram=False,
1351
+ )
1352
+ new_count += 1
1353
+
1354
+ # Telegram 通知(僅近期且已完成的操作)
1355
+ should_notify = (
1356
+ task_type in NOTIFY_TYPES
1357
+ and status == "OK"
1358
+ and start_ts and start_ts >= notify_cutoff
1359
+ )
1360
+ if should_notify:
1361
+ label = TASK_LABELS.get(task_type, task_type)
1362
+ msg = (
1363
+ f"{label}\n\n"
1364
+ f"🖥️ VM: <b>{vm_name}</b> (VMID: {vmid})\n"
1365
+ f"👤 操作者: <code>{operator}</code>\n"
1366
+ f"📌 節點: <code>{node_name}</code>\n"
1367
+ f"🔀 叢集: {self.cluster_name}\n"
1368
+ f"📅 時間: {start_dt.strftime('%Y-%m-%d %H:%M:%S')}"
1369
+ )
1370
+ self.send_telegram_notification(msg)
1371
+ log.notified_telegram = True
1372
+ log.save(update_fields=["notified_telegram"])
1373
+
1374
+ if new_count > 0:
1375
+ print(f" 📋 新增 {new_count} 筆 VM 操作記錄")
1376
+
1238
1377
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1239
1378
  current_tags: List[str], network_interfaces: List[Dict] = None,
1240
1379
  current_node: str = None) -> List[Dict]:
@@ -1576,14 +1715,18 @@ class OptimizedPVEToNetBoxSync:
1576
1715
  # 但 state_db 仍有舊 hash 導致增量跳過。強制往下完整同步。
1577
1716
  print(f" ⚠️ VM {original_vm_name} 不在 NetBox 中,忽略增量記錄強制建立")
1578
1717
  else:
1579
- # VM 存在;確保 device 關聯指向正確節點
1718
+ # VM 存在;確保 device 關聯、cluster 關聯、電源狀態都是最新的
1580
1719
  current_device_id = getattr(getattr(cached_vm, 'device', None), 'id', None)
1581
1720
  current_cluster_id = getattr(getattr(cached_vm, 'cluster', None), 'id', None)
1721
+ current_status = getattr(getattr(cached_vm, 'status', None), 'value', None) \
1722
+ or str(getattr(cached_vm, 'status', ''))
1582
1723
  fix = {}
1583
1724
  if current_device_id != device.id:
1584
1725
  fix['device'] = device.id
1585
1726
  if current_cluster_id != cluster['id']:
1586
1727
  fix['cluster'] = cluster['id']
1728
+ if current_status != status:
1729
+ fix['status'] = status
1587
1730
  if fix:
1588
1731
  try:
1589
1732
  cached_vm.update(fix)
@@ -1591,6 +1734,8 @@ class OptimizedPVEToNetBoxSync:
1591
1734
  print(f" ✓ VM {original_vm_name} 叢集關聯已修正: → {cluster['name']}")
1592
1735
  if 'device' in fix:
1593
1736
  print(f" ✓ VM {original_vm_name} 設備關聯已修正: → {device.name}")
1737
+ if 'status' in fix:
1738
+ print(f" ✓ VM {original_vm_name} 電源狀態更新: {current_status} → {status}")
1594
1739
  except Exception as e:
1595
1740
  print(f" ✗ 更新 VM {original_vm_name} 關聯失敗: {e}")
1596
1741
  print(f" ℹ️ VM {original_vm_name} 無配置變更,跳過同步")
@@ -1809,6 +1954,9 @@ class OptimizedPVEToNetBoxSync:
1809
1954
  print("\n" + "=" * 50)
1810
1955
  print("✓ 節點同步成功")
1811
1956
  vms_success, success_count, total_count = self.sync_pve_virtual_machines(devices, cluster)
1957
+ print("\n同步 VM 操作記錄...")
1958
+ self.sync_vm_tasks()
1959
+ self._apply_log_retention()
1812
1960
  elapsed = time.time() - start_time
1813
1961
  self.stats['elapsed_time'] = elapsed
1814
1962
  self.stats['total_vms'] = total_count
@@ -9,6 +9,7 @@ from pve_sync_plugin.models import (
9
9
  PveClusterConfig,
10
10
  PvePluginSettings,
11
11
  PveSyncJob,
12
+ PveVmTaskLog,
12
13
  PveWebhookEvent,
13
14
  VmProvisioningLog,
14
15
  )
@@ -177,3 +178,19 @@ class VmProvisioningLogSerializer(NetBoxModelSerializer):
177
178
  "status", "notes", "checklist",
178
179
  "created", "last_updated",
179
180
  ]
181
+
182
+
183
+ class PveVmTaskLogSerializer(NetBoxModelSerializer):
184
+ url = serializers.HyperlinkedIdentityField(
185
+ view_name="plugins-api:pve_sync_plugin-api:pvevmtasklog-detail",
186
+ )
187
+
188
+ class Meta:
189
+ model = PveVmTaskLog
190
+ fields = [
191
+ "id", "url", "display",
192
+ "upid", "vmid", "vm_name", "cluster_name", "node",
193
+ "task_type", "operator", "start_time", "end_time",
194
+ "status", "notified_telegram",
195
+ "created", "last_updated",
196
+ ]
@@ -9,7 +9,7 @@ from netbox.api.routers import NetBoxRouter
9
9
  from pve_sync_plugin import views as plugin_views
10
10
 
11
11
  from . import views as api_views
12
- from .views import VmProvisioningLogViewSet
12
+ from .views import PveVmTaskLogViewSet, VmProvisioningLogViewSet
13
13
 
14
14
  app_name = "pve_sync_plugin-api"
15
15
 
@@ -22,6 +22,7 @@ router.register("clusters", api_views.PveClusterConfigViewSet)
22
22
  router.register("backup-status", api_views.PveBackupStatusViewSet)
23
23
  router.register("settings", api_views.PvePluginSettingsViewSet)
24
24
  router.register("provisioning", api_views.VmProvisioningLogViewSet)
25
+ router.register("task-logs", api_views.PveVmTaskLogViewSet)
25
26
 
26
27
  urlpatterns = [
27
28
  path("webhook/", plugin_views.webhook_receiver, name="webhook"),
@@ -17,6 +17,7 @@ from pve_sync_plugin.models import (
17
17
  PveClusterConfig,
18
18
  PvePluginSettings,
19
19
  PveSyncJob,
20
+ PveVmTaskLog,
20
21
  PveWebhookEvent,
21
22
  VmProvisioningLog,
22
23
  )
@@ -26,6 +27,7 @@ from .serializers import (
26
27
  PveClusterConfigSerializer,
27
28
  PvePluginSettingsSerializer,
28
29
  PveSyncJobSerializer,
30
+ PveVmTaskLogSerializer,
29
31
  PveWebhookEventSerializer,
30
32
  VmProvisioningLogSerializer,
31
33
  )
@@ -99,3 +101,8 @@ class PvePluginSettingsViewSet(NetBoxModelViewSet):
99
101
  class VmProvisioningLogViewSet(NetBoxModelViewSet):
100
102
  queryset = VmProvisioningLog.objects.order_by("-created")
101
103
  serializer_class = VmProvisioningLogSerializer
104
+
105
+
106
+ class PveVmTaskLogViewSet(NetBoxModelViewSet):
107
+ queryset = PveVmTaskLog.objects.order_by("-start_time")
108
+ serializer_class = PveVmTaskLogSerializer
@@ -45,6 +45,19 @@ class SyncScheduleChoices(models.TextChoices):
45
45
  WEEKLY = "weekly", "Weekly"
46
46
 
47
47
 
48
+ class VmTaskTypeChoices(models.TextChoices):
49
+ CREATE = "qmcreate", "建立 VM"
50
+ DESTROY = "qmdestroy", "刪除 VM"
51
+ CLONE = "qmclone", "Clone VM"
52
+ MIGRATE = "qmmigrate", "遷移 VM"
53
+ RESTORE = "qmrestore", "還原 VM"
54
+ BACKUP = "vzdump", "備份 VM"
55
+ START = "qmstart", "開機"
56
+ STOP = "qmstop", "強制關機"
57
+ SHUTDOWN = "qmshutdown", "正常關機"
58
+ REBOOT = "qmreboot", "重新開機"
59
+
60
+
48
61
  class DriftTypeChoices(models.TextChoices):
49
62
  HARDWARE = "hardware", "硬體配置變更"
50
63
  MIGRATION = "migration", "VM 遷移"
@@ -10,6 +10,7 @@ from .choices import (
10
10
  SyncJobStatusChoices,
11
11
  SyncJobTriggerChoices,
12
12
  SyncScheduleChoices,
13
+ VmTaskTypeChoices,
13
14
  WebhookEventChoices,
14
15
  )
15
16
  from .models import (
@@ -18,6 +19,7 @@ from .models import (
18
19
  PveClusterConfig,
19
20
  PveDriftEvent,
20
21
  PveSyncJob,
22
+ PveVmTaskLog,
21
23
  PveWebhookEvent,
22
24
  )
23
25
 
@@ -120,6 +122,29 @@ class PveDriftEventFilterSet(NetBoxModelFilterSet):
120
122
  return queryset.filter(vm_name__icontains=value) | queryset.filter(cluster_name__icontains=value)
121
123
 
122
124
 
125
+ class PveVmTaskLogFilterSet(NetBoxModelFilterSet):
126
+ """Filter VM task logs by type, operator, cluster, and VM."""
127
+
128
+ task_type = django_filters.MultipleChoiceFilter(choices=VmTaskTypeChoices.choices)
129
+ vmid = django_filters.NumberFilter()
130
+ cluster_name = django_filters.CharFilter(lookup_expr="icontains")
131
+ operator = django_filters.CharFilter(lookup_expr="icontains")
132
+ node = django_filters.CharFilter(lookup_expr="icontains")
133
+
134
+ class Meta:
135
+ model = PveVmTaskLog
136
+ fields = ["id", "vmid", "task_type", "cluster_name", "operator", "node"]
137
+
138
+ def search(self, queryset, name, value):
139
+ if not value.strip():
140
+ return queryset
141
+ return (
142
+ queryset.filter(vm_name__icontains=value)
143
+ | queryset.filter(operator__icontains=value)
144
+ | queryset.filter(cluster_name__icontains=value)
145
+ )
146
+
147
+
123
148
  class PveBackupStatusFilterSet(NetBoxModelFilterSet):
124
149
  """Filter backup statuses by state."""
125
150
 
@@ -0,0 +1,55 @@
1
+ # Generated by Django 6.0.5 on 2026-06-22 16:57
2
+
3
+ import netbox.models.deletion
4
+ import taggit.managers
5
+ import utilities.json
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ('extras', '0138_customfieldchoiceset_choice_colors'),
13
+ ('pve_sync_plugin', '0014_vmprovisioninglog'),
14
+ ]
15
+
16
+ operations = [
17
+ migrations.AddField(
18
+ model_name='vmprovisioninglog',
19
+ name='tags',
20
+ field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
21
+ ),
22
+ migrations.AlterField(
23
+ model_name='vmprovisioninglog',
24
+ name='custom_field_data',
25
+ field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
26
+ ),
27
+ migrations.CreateModel(
28
+ name='PveVmTaskLog',
29
+ fields=[
30
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
31
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
32
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
33
+ ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
34
+ ('upid', models.CharField(max_length=300, unique=True)),
35
+ ('vmid', models.IntegerField()),
36
+ ('vm_name', models.CharField(blank=True, max_length=200)),
37
+ ('cluster_name', models.CharField(max_length=100)),
38
+ ('node', models.CharField(max_length=100)),
39
+ ('task_type', models.CharField(max_length=30)),
40
+ ('operator', models.CharField(max_length=200)),
41
+ ('start_time', models.DateTimeField()),
42
+ ('end_time', models.DateTimeField(blank=True, null=True)),
43
+ ('status', models.CharField(blank=True, max_length=50)),
44
+ ('notified_telegram', models.BooleanField(default=False)),
45
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
46
+ ],
47
+ options={
48
+ 'verbose_name': 'VM Task Log',
49
+ 'verbose_name_plural': 'VM Task Logs',
50
+ 'ordering': ['-start_time'],
51
+ 'indexes': [models.Index(fields=['vmid', 'cluster_name'], name='pve_sync_pl_vmid_00b115_idx'), models.Index(fields=['task_type'], name='pve_sync_pl_task_ty_0f86cb_idx'), models.Index(fields=['operator'], name='pve_sync_pl_operato_6b34b5_idx'), models.Index(fields=['start_time'], name='pve_sync_pl_start_t_dac39f_idx')],
52
+ },
53
+ bases=(netbox.models.deletion.DeleteMixin, models.Model),
54
+ ),
55
+ ]
@@ -0,0 +1,18 @@
1
+ # Generated by Django 6.0.5 on 2026-06-23 00:57
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ('pve_sync_plugin', '0015_pvevmtasklog'),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name='pvevmtasklog',
15
+ name='status',
16
+ field=models.TextField(blank=True),
17
+ ),
18
+ ]
@@ -12,6 +12,7 @@ from .choices import (
12
12
  SyncJobStatusChoices,
13
13
  SyncJobTriggerChoices,
14
14
  SyncScheduleChoices,
15
+ VmTaskTypeChoices,
15
16
  WebhookEventChoices,
16
17
  )
17
18
 
@@ -514,3 +515,59 @@ class VmProvisioningLog(NetBoxModel):
514
515
  ("chk_qemu_install", "安裝 qemu-guest-agent 套件"),
515
516
  ("chk_qemu_enable", "啟用並啟動 qemu-guest-agent 服務"),
516
517
  ]
518
+
519
+
520
+ class PveVmTaskLog(NetBoxModel):
521
+ """Records VM lifecycle operations fetched from PVE task history."""
522
+
523
+ upid = models.CharField(max_length=300, unique=True, verbose_name="UPID")
524
+ vmid = models.IntegerField(verbose_name="VM ID")
525
+ vm_name = models.CharField(max_length=200, blank=True, verbose_name="VM 名稱")
526
+ cluster_name = models.CharField(max_length=100, verbose_name="叢集")
527
+ node = models.CharField(max_length=100, verbose_name="節點")
528
+ task_type = models.CharField(max_length=30, choices=VmTaskTypeChoices, verbose_name="操作類型")
529
+ operator = models.CharField(max_length=200, verbose_name="操作者")
530
+ start_time = models.DateTimeField(verbose_name="開始時間")
531
+ end_time = models.DateTimeField(null=True, blank=True, verbose_name="結束時間")
532
+ status = models.TextField(blank=True, verbose_name="狀態")
533
+ notified_telegram = models.BooleanField(default=False, verbose_name="已通知")
534
+
535
+ NOTIFY_TYPES = {"qmdestroy", "qmclone", "qmrestore"}
536
+
537
+ TASK_ICONS = {
538
+ "qmcreate": ("mdi mdi-plus-circle", "success"),
539
+ "qmdestroy": ("mdi mdi-delete", "danger"),
540
+ "qmclone": ("mdi mdi-content-copy", "info"),
541
+ "qmmigrate": ("mdi mdi-transfer", "warning"),
542
+ "qmrestore": ("mdi mdi-restore", "primary"),
543
+ "vzdump": ("mdi mdi-backup-restore", "secondary"),
544
+ "qmstart": ("mdi mdi-play-circle", "success"),
545
+ "qmstop": ("mdi mdi-stop-circle", "danger"),
546
+ "qmshutdown": ("mdi mdi-power", "warning"),
547
+ "qmreboot": ("mdi mdi-restart", "info"),
548
+ }
549
+
550
+ class Meta:
551
+ ordering = ["-start_time"]
552
+ indexes = [
553
+ models.Index(fields=["vmid", "cluster_name"]),
554
+ models.Index(fields=["task_type"]),
555
+ models.Index(fields=["operator"]),
556
+ models.Index(fields=["start_time"]),
557
+ ]
558
+ verbose_name = "VM Task Log"
559
+ verbose_name_plural = "VM Task Logs"
560
+
561
+ def __str__(self):
562
+ return f"{self.get_task_type_display()} — {self.vm_name} (VMID {self.vmid}) by {self.operator}"
563
+
564
+ def get_absolute_url(self):
565
+ return reverse("plugins:pve_sync_plugin:pvevmtasklog_list")
566
+
567
+ @property
568
+ def task_icon(self):
569
+ return self.TASK_ICONS.get(self.task_type, ("mdi mdi-history", "secondary"))
570
+
571
+ @property
572
+ def status_ok(self):
573
+ return self.status == "OK"
@@ -75,6 +75,11 @@ vm_provisioning_item = PluginMenuItem(
75
75
  link_text="VM 佈建",
76
76
  )
77
77
 
78
+ vm_task_log_item = PluginMenuItem(
79
+ link="plugins:pve_sync_plugin:pvevmtasklog_list",
80
+ link_text="VM 操作記錄",
81
+ )
82
+
78
83
  menu = PluginMenu(
79
84
  label="Proxmox Sync",
80
85
  groups=(
@@ -92,7 +97,7 @@ menu = PluginMenu(
92
97
  ),
93
98
  (
94
99
  "VM Operations",
95
- (vm_provisioning_item,),
100
+ (vm_provisioning_item, vm_task_log_item),
96
101
  ),
97
102
  (
98
103
  "Configuration",
@@ -10,6 +10,7 @@ from .models import (
10
10
  PveClusterConfig,
11
11
  PveDriftEvent,
12
12
  PveSyncJob,
13
+ PveVmTaskLog,
13
14
  PveWebhookEvent,
14
15
  VmProvisioningLog,
15
16
  )
@@ -299,3 +300,27 @@ class VmProvisioningLogTable(NetBoxTable):
299
300
  "management_ip", "internet_ip", "created")
300
301
  default_columns = ("vm_name", "vmid", "cluster_name", "node", "status",
301
302
  "management_ip", "internet_ip")
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # PveVmTaskLog
307
+ # ---------------------------------------------------------------------------
308
+
309
+ class PveVmTaskLogTable(NetBoxTable):
310
+ vm_name = tables.Column(verbose_name="VM 名稱")
311
+ vmid = tables.Column(verbose_name="VM ID")
312
+ cluster_name = tables.Column(verbose_name="叢集")
313
+ node = tables.Column(verbose_name="節點")
314
+ task_type = columns.ChoiceFieldColumn(verbose_name="操作類型")
315
+ operator = tables.Column(verbose_name="操作者")
316
+ start_time = columns.DateTimeColumn(verbose_name="時間")
317
+ status = tables.Column(verbose_name="結果")
318
+ notified_telegram = columns.BooleanColumn(verbose_name="已通知")
319
+ actions = columns.ActionsColumn(actions=("delete",))
320
+
321
+ class Meta(NetBoxTable.Meta):
322
+ model = PveVmTaskLog
323
+ fields = ("pk", "vm_name", "vmid", "cluster_name", "node",
324
+ "task_type", "operator", "start_time", "status", "notified_telegram")
325
+ default_columns = ("vm_name", "vmid", "cluster_name", "node",
326
+ "task_type", "operator", "start_time", "status")
@@ -2,7 +2,7 @@
2
2
 
3
3
  from netbox.plugins import PluginTemplateExtension
4
4
 
5
- from .models import PveClusterConfig, PveSyncJob
5
+ from .models import PveClusterConfig, PveSyncJob, PveVmTaskLog
6
6
 
7
7
 
8
8
  class VirtualMachineSyncButton(PluginTemplateExtension):
@@ -41,4 +41,37 @@ class VirtualMachineSyncButton(PluginTemplateExtension):
41
41
  )
42
42
 
43
43
 
44
- template_extensions = [VirtualMachineSyncButton]
44
+ class VmTaskLogPanel(PluginTemplateExtension):
45
+ """Inject a VM operation history panel into NetBox VM detail pages."""
46
+
47
+ models = ["virtualization.virtualmachine"]
48
+
49
+ def full_width_page(self):
50
+ vm = self.context["object"]
51
+
52
+ if not vm.serial:
53
+ return ""
54
+
55
+ try:
56
+ vmid = int(vm.serial)
57
+ except (ValueError, TypeError):
58
+ return ""
59
+
60
+ qs = PveVmTaskLog.objects.filter(vmid=vmid)
61
+ cluster_config = None
62
+ if vm.cluster_id:
63
+ cluster_config = PveClusterConfig.objects.filter(
64
+ netbox_cluster_id=vm.cluster_id, enabled=True,
65
+ ).first()
66
+ if cluster_config:
67
+ qs = qs.filter(cluster_name=cluster_config.name)
68
+
69
+ task_logs = qs.order_by("-start_time")[:30]
70
+
71
+ return self.render(
72
+ "pve_sync/inc/vm_task_log_panel.html",
73
+ extra_context={"task_logs": task_logs, "vmid": vmid},
74
+ )
75
+
76
+
77
+ template_extensions = [VirtualMachineSyncButton, VmTaskLogPanel]
@@ -0,0 +1,57 @@
1
+ {% load helpers %}
2
+
3
+ <div class="card mt-3">
4
+ <div class="card-header d-flex justify-content-between align-items-center">
5
+ <h5 class="mb-0"><i class="mdi mdi-history me-2"></i>PVE 操作記錄</h5>
6
+ <a href="{% url 'plugins:pve_sync_plugin:pvevmtasklog_list' %}?vmid={{ vmid }}"
7
+ class="btn btn-sm btn-outline-secondary">查看全部</a>
8
+ </div>
9
+ <div class="card-body p-0">
10
+ {% if task_logs %}
11
+ <table class="table table-hover table-sm mb-0">
12
+ <thead class="table-secondary">
13
+ <tr>
14
+ <th>操作類型</th>
15
+ <th>操作者</th>
16
+ <th>節點</th>
17
+ <th>時間</th>
18
+ <th>結果</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {% for log in task_logs %}
23
+ <tr>
24
+ <td>
25
+ {% with log.task_icon as icon %}
26
+ <i class="{{ icon.0 }} text-{{ icon.1 }} me-1"></i>
27
+ {{ log.get_task_type_display }}
28
+ {% endwith %}
29
+ </td>
30
+ <td><code>{{ log.operator }}</code></td>
31
+ <td>{{ log.node }}</td>
32
+ <td>
33
+ <span title="{{ log.start_time }}">
34
+ {{ log.start_time|date:"Y-m-d H:i:s" }}
35
+ </span>
36
+ </td>
37
+ <td>
38
+ {% if log.status_ok %}
39
+ <span class="badge bg-success">OK</span>
40
+ {% elif log.status %}
41
+ <span class="badge bg-danger" title="{{ log.status }}">ERROR</span>
42
+ {% else %}
43
+ <span class="badge bg-secondary">—</span>
44
+ {% endif %}
45
+ </td>
46
+ </tr>
47
+ {% endfor %}
48
+ </tbody>
49
+ </table>
50
+ {% else %}
51
+ <div class="text-center text-muted py-4">
52
+ <i class="mdi mdi-history mdi-48px d-block mb-2"></i>
53
+ 尚無操作記錄(下次同步後自動載入)
54
+ </div>
55
+ {% endif %}
56
+ </div>
57
+ </div>
@@ -0,0 +1,10 @@
1
+ {% extends 'generic/object_list.html' %}
2
+ {% load helpers %}
3
+
4
+ {% block title %}VM 操作記錄{% endblock %}
5
+
6
+ {% block extra_controls %}
7
+ <div class="d-flex gap-2 align-items-center">
8
+ <span class="text-muted small">由 PVE Task History API 自動同步</span>
9
+ </div>
10
+ {% endblock %}
@@ -10,7 +10,7 @@ from django.urls import path
10
10
  from netbox.views.generic import ObjectChangeLogView
11
11
 
12
12
  from . import views
13
- from .models import PbsServerConfig, PveBackupStatus, PveClusterConfig, PveDriftEvent, PveSyncJob, PveWebhookEvent, VmProvisioningLog
13
+ from .models import PbsServerConfig, PveBackupStatus, PveClusterConfig, PveDriftEvent, PveSyncJob, PveVmTaskLog, PveWebhookEvent, VmProvisioningLog
14
14
 
15
15
  app_name = "pve_sync_plugin"
16
16
 
@@ -181,6 +181,13 @@ urlpatterns = [
181
181
  kwargs={"model": PveDriftEvent},
182
182
  ),
183
183
 
184
+ # --- PveVmTaskLog ---
185
+ path("task-logs/", views.PveVmTaskLogListView.as_view(), name="pvevmtasklog_list"),
186
+ path("task-logs/<int:pk>/delete/", views.PveVmTaskLogDeleteView.as_view(), name="pvevmtasklog_delete"),
187
+ path("task-logs/delete/", views.PveVmTaskLogBulkDeleteView.as_view(), name="pvevmtasklog_bulk_delete"),
188
+ path("task-logs/<int:pk>/changelog/", ObjectChangeLogView.as_view(),
189
+ name="pvevmtasklog_changelog", kwargs={"model": PveVmTaskLog}),
190
+
184
191
  # IP probe APIs (used by provisioning form)
185
192
  path("provisioning/free-ips/<int:range_id>/", views.VmPlannerFreeIpsApi.as_view(), name="vm-planner-free-ips"),
186
193
  path("provisioning/check-ip/", views.VmPlannerCheckIpApi.as_view(), name="vm-planner-check-ip"),
@@ -30,6 +30,7 @@ from .filtersets import (
30
30
  PveClusterConfigFilterSet,
31
31
  PveDriftEventFilterSet,
32
32
  PveSyncJobFilterSet,
33
+ PveVmTaskLogFilterSet,
33
34
  PveWebhookEventFilterSet,
34
35
  )
35
36
  from .forms import (
@@ -49,6 +50,7 @@ from .models import (
49
50
  PveDriftEvent,
50
51
  PvePluginSettings,
51
52
  PveSyncJob,
53
+ PveVmTaskLog,
52
54
  PveWebhookEvent,
53
55
  VmProvisioningLog,
54
56
  )
@@ -58,6 +60,7 @@ from .tables import (
58
60
  PveClusterConfigTable,
59
61
  PveDriftEventTable,
60
62
  PveSyncJobTable,
63
+ PveVmTaskLogTable,
61
64
  PveWebhookEventTable,
62
65
  VmProvisioningLogTable,
63
66
  )
@@ -342,6 +345,26 @@ class PveDriftEventBulkDeleteView(generic.BulkDeleteView):
342
345
  table = PveDriftEventTable
343
346
 
344
347
 
348
+ # ============================================================================
349
+ # PveVmTaskLog — read-only list from PVE task history
350
+ # ============================================================================
351
+
352
+ class PveVmTaskLogListView(generic.ObjectListView):
353
+ queryset = PveVmTaskLog.objects.order_by("-start_time")
354
+ table = PveVmTaskLogTable
355
+ filterset = PveVmTaskLogFilterSet
356
+ template_name = "pve_sync/pvevmtasklog_list.html"
357
+
358
+
359
+ class PveVmTaskLogDeleteView(generic.ObjectDeleteView):
360
+ queryset = PveVmTaskLog.objects.all()
361
+
362
+
363
+ class PveVmTaskLogBulkDeleteView(generic.BulkDeleteView):
364
+ queryset = PveVmTaskLog.objects.all()
365
+ table = PveVmTaskLogTable
366
+
367
+
345
368
  # ============================================================================
346
369
  # Plugin Settings (singleton)
347
370
  # ============================================================================
@@ -645,13 +668,12 @@ class VmPlannerCheckIpApi(PermissionRequiredMixin, View):
645
668
  ip_str = str(ip_obj)
646
669
 
647
670
  # ── 1. IPAM check (authoritative) ──
648
- # An IP can be stored as any prefix length (e.g. /16, /24, /32).
649
- # Compare only the host part to find any existing allocation.
650
- ipam_entry = None
651
- for addr in NetBoxIPAddress.objects.all().values_list("address", flat=True):
652
- if str(addr.ip) == ip_str:
653
- ipam_entry = str(addr) # e.g. "172.17.202.11/16"
654
- break
671
+ # Filter by host bits using __net_host_contained: finds any /N record
672
+ # whose host address matches ip_str, without a full table scan.
673
+ ipam_qs = NetBoxIPAddress.objects.filter(
674
+ address__net_host_contained=ip_str
675
+ ).values_list("address", flat=True).first()
676
+ ipam_entry = str(ipam_qs) if ipam_qs else None
655
677
 
656
678
  if ipam_entry:
657
679
  return JsonResponse({
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxsync"
7
- version = "2.0.2"
7
+ version = "2.0.4"
8
8
  description = "NetBox plugin for Proxmox VE inventory synchronization"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes