proxsync 2.0.2__tar.gz → 2.0.3__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.3}/PKG-INFO +1 -1
  2. {proxsync-2.0.2 → proxsync-2.0.3/proxsync.egg-info}/PKG-INFO +1 -1
  3. {proxsync-2.0.2 → proxsync-2.0.3}/proxsync.egg-info/SOURCES.txt +4 -0
  4. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync.py +123 -1
  5. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/api/serializers.py +17 -0
  6. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/api/urls.py +2 -1
  7. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/api/views.py +7 -0
  8. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/choices.py +13 -0
  9. proxsync-2.0.3/pve_sync_plugin/migrations/0015_pvevmtasklog.py +55 -0
  10. proxsync-2.0.3/pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py +18 -0
  11. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/models.py +57 -0
  12. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/navigation.py +6 -1
  13. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/tables.py +25 -0
  14. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/template_content.py +35 -2
  15. proxsync-2.0.3/pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html +57 -0
  16. proxsync-2.0.3/pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html +10 -0
  17. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/urls.py +8 -1
  18. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/views.py +21 -0
  19. {proxsync-2.0.2 → proxsync-2.0.3}/pyproject.toml +1 -1
  20. {proxsync-2.0.2 → proxsync-2.0.3}/MANIFEST.in +0 -0
  21. {proxsync-2.0.2 → proxsync-2.0.3}/README.md +0 -0
  22. {proxsync-2.0.2 → proxsync-2.0.3}/config.py +0 -0
  23. {proxsync-2.0.2 → proxsync-2.0.3}/pbs_sync.py +0 -0
  24. {proxsync-2.0.2 → proxsync-2.0.3}/proxsync.egg-info/dependency_links.txt +0 -0
  25. {proxsync-2.0.2 → proxsync-2.0.3}/proxsync.egg-info/requires.txt +0 -0
  26. {proxsync-2.0.2 → proxsync-2.0.3}/proxsync.egg-info/top_level.txt +0 -0
  27. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/__init__.py +0 -0
  28. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/api/__init__.py +0 -0
  29. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/apps.py +0 -0
  30. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/filtersets.py +0 -0
  31. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/forms.py +0 -0
  32. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/management/__init__.py +0 -0
  33. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/management/commands/__init__.py +0 -0
  34. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  35. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  36. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  37. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  38. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  39. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  40. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  41. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  42. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  43. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  44. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  45. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  46. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  47. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  48. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  49. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +0 -0
  50. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/migrations/__init__.py +0 -0
  51. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/signals.py +0 -0
  52. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/sync/__init__.py +0 -0
  53. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/sync/config_bridge.py +0 -0
  54. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/sync/engine.py +0 -0
  55. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/tasks.py +0 -0
  56. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  57. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  58. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  59. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  60. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  61. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  62. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +0 -0
  63. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  64. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  65. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +0 -0
  66. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +0 -0
  67. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templatetags/__init__.py +0 -0
  68. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/templatetags/pve_sync_tags.py +0 -0
  69. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/utils.py +0 -0
  70. {proxsync-2.0.2 → proxsync-2.0.3}/pve_sync_plugin/view_helpers.py +0 -0
  71. {proxsync-2.0.2 → proxsync-2.0.3}/setup.cfg +0 -0
  72. {proxsync-2.0.2 → proxsync-2.0.3}/state_db.py +0 -0
  73. {proxsync-2.0.2 → proxsync-2.0.3}/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.3
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.3
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,120 @@ class OptimizedPVEToNetBoxSync:
1235
1235
  print(f"⚠️ 清理孤兒 VM 失敗: {exc}")
1236
1236
  traceback.print_exc()
1237
1237
 
1238
+ def sync_vm_tasks(self):
1239
+ """從 PVE 各節點抓取 VM 操作記錄(建立/刪除/Clone/遷移)並同步到 NetBox。"""
1240
+ try:
1241
+ from django.utils import timezone
1242
+ from pve_sync_plugin.models import PveVmTaskLog
1243
+ except ImportError:
1244
+ return
1245
+
1246
+ VM_TASK_TYPES = {
1247
+ "qmcreate", "qmdestroy", "qmclone", "qmmigrate", "qmrestore",
1248
+ "qmstart", "qmstop", "qmshutdown", "qmreboot",
1249
+ }
1250
+ NOTIFY_TYPES = {"qmdestroy", "qmclone", "qmrestore", "qmshutdown", "qmstop"}
1251
+ TASK_LABELS = {
1252
+ "qmcreate": "🆕 VM 建立",
1253
+ "qmdestroy": "🗑️ VM 刪除",
1254
+ "qmclone": "📋 VM Clone",
1255
+ "qmmigrate": "🔀 VM 遷移",
1256
+ "qmrestore": "♻️ VM 還原",
1257
+ "qmstart": "▶️ VM 開機",
1258
+ "qmstop": "⏹️ VM 強制關機",
1259
+ "qmshutdown": "🔽 VM 正常關機",
1260
+ "qmreboot": "🔄 VM 重新開機",
1261
+ }
1262
+
1263
+ # 從 NetBox cache 建立 vmid → vm_name 對照
1264
+ vmid_to_name: dict = {}
1265
+ for key, vm in self.nb_cache.get("virtual_machines_by_serial", {}).items():
1266
+ try:
1267
+ vmid_to_name[int(vm.serial)] = vm.name
1268
+ except (ValueError, TypeError, AttributeError):
1269
+ pass
1270
+
1271
+ # 只通知最近 2 小時內的操作(避免首次匯入時大量通知)
1272
+ notify_cutoff = time.time() - 7200
1273
+
1274
+ since_ts = int(time.time()) - 30 * 86400 # 抓最近 30 天
1275
+ new_count = 0
1276
+
1277
+ for node_name in self.pve_cache.get("vms_by_node", {}):
1278
+ try:
1279
+ tasks = self.pve_api.nodes(node_name).tasks.get(since=since_ts, limit=1000)
1280
+ except Exception as exc:
1281
+ print(f" ⚠️ 無法取得節點 {node_name} 的操作記錄: {exc}")
1282
+ continue
1283
+
1284
+ for task in tasks:
1285
+ if task.get("type") not in VM_TASK_TYPES:
1286
+ continue
1287
+
1288
+ upid = task.get("upid", "")
1289
+ if not upid:
1290
+ continue
1291
+
1292
+ vmid_str = task.get("id", "")
1293
+ try:
1294
+ vmid = int(vmid_str)
1295
+ except (ValueError, TypeError):
1296
+ continue
1297
+
1298
+ # UPID 去重
1299
+ if PveVmTaskLog.objects.filter(upid=upid).exists():
1300
+ continue
1301
+
1302
+ task_type = task.get("type")
1303
+ operator = task.get("user", "unknown")
1304
+ start_ts = task.get("starttime")
1305
+ end_ts = task.get("endtime")
1306
+ status = task.get("status", "") or ""
1307
+ vm_name = vmid_to_name.get(vmid, f"VM-{vmid}")
1308
+
1309
+ import datetime as _dt
1310
+ _utc = _dt.timezone.utc
1311
+ start_dt = _dt.datetime.fromtimestamp(start_ts, tz=_utc) if start_ts else timezone.now()
1312
+ end_dt = _dt.datetime.fromtimestamp(end_ts, tz=_utc) if end_ts else None
1313
+
1314
+ log = PveVmTaskLog.objects.create(
1315
+ upid=upid,
1316
+ vmid=vmid,
1317
+ vm_name=vm_name,
1318
+ cluster_name=self.cluster_name,
1319
+ node=node_name,
1320
+ task_type=task_type,
1321
+ operator=operator,
1322
+ start_time=start_dt,
1323
+ end_time=end_dt,
1324
+ status=status,
1325
+ notified_telegram=False,
1326
+ )
1327
+ new_count += 1
1328
+
1329
+ # Telegram 通知(僅近期且已完成的操作)
1330
+ should_notify = (
1331
+ task_type in NOTIFY_TYPES
1332
+ and status == "OK"
1333
+ and start_ts and start_ts >= notify_cutoff
1334
+ )
1335
+ if should_notify:
1336
+ label = TASK_LABELS.get(task_type, task_type)
1337
+ msg = (
1338
+ f"{label}\n\n"
1339
+ f"🖥️ VM: <b>{vm_name}</b> (VMID: {vmid})\n"
1340
+ f"👤 操作者: <code>{operator}</code>\n"
1341
+ f"📌 節點: <code>{node_name}</code>\n"
1342
+ f"🔀 叢集: {self.cluster_name}\n"
1343
+ f"📅 時間: {start_dt.strftime('%Y-%m-%d %H:%M:%S')}"
1344
+ )
1345
+ self.send_telegram_notification(msg)
1346
+ log.notified_telegram = True
1347
+ log.save(update_fields=["notified_telegram"])
1348
+
1349
+ if new_count > 0:
1350
+ print(f" 📋 新增 {new_count} 筆 VM 操作記錄")
1351
+
1238
1352
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1239
1353
  current_tags: List[str], network_interfaces: List[Dict] = None,
1240
1354
  current_node: str = None) -> List[Dict]:
@@ -1576,14 +1690,18 @@ class OptimizedPVEToNetBoxSync:
1576
1690
  # 但 state_db 仍有舊 hash 導致增量跳過。強制往下完整同步。
1577
1691
  print(f" ⚠️ VM {original_vm_name} 不在 NetBox 中,忽略增量記錄強制建立")
1578
1692
  else:
1579
- # VM 存在;確保 device 關聯指向正確節點
1693
+ # VM 存在;確保 device 關聯、cluster 關聯、電源狀態都是最新的
1580
1694
  current_device_id = getattr(getattr(cached_vm, 'device', None), 'id', None)
1581
1695
  current_cluster_id = getattr(getattr(cached_vm, 'cluster', None), 'id', None)
1696
+ current_status = getattr(getattr(cached_vm, 'status', None), 'value', None) \
1697
+ or str(getattr(cached_vm, 'status', ''))
1582
1698
  fix = {}
1583
1699
  if current_device_id != device.id:
1584
1700
  fix['device'] = device.id
1585
1701
  if current_cluster_id != cluster['id']:
1586
1702
  fix['cluster'] = cluster['id']
1703
+ if current_status != status:
1704
+ fix['status'] = status
1587
1705
  if fix:
1588
1706
  try:
1589
1707
  cached_vm.update(fix)
@@ -1591,6 +1709,8 @@ class OptimizedPVEToNetBoxSync:
1591
1709
  print(f" ✓ VM {original_vm_name} 叢集關聯已修正: → {cluster['name']}")
1592
1710
  if 'device' in fix:
1593
1711
  print(f" ✓ VM {original_vm_name} 設備關聯已修正: → {device.name}")
1712
+ if 'status' in fix:
1713
+ print(f" ✓ VM {original_vm_name} 電源狀態更新: {current_status} → {status}")
1594
1714
  except Exception as e:
1595
1715
  print(f" ✗ 更新 VM {original_vm_name} 關聯失敗: {e}")
1596
1716
  print(f" ℹ️ VM {original_vm_name} 無配置變更,跳過同步")
@@ -1809,6 +1929,8 @@ class OptimizedPVEToNetBoxSync:
1809
1929
  print("\n" + "=" * 50)
1810
1930
  print("✓ 節點同步成功")
1811
1931
  vms_success, success_count, total_count = self.sync_pve_virtual_machines(devices, cluster)
1932
+ print("\n同步 VM 操作記錄...")
1933
+ self.sync_vm_tasks()
1812
1934
  elapsed = time.time() - start_time
1813
1935
  self.stats['elapsed_time'] = elapsed
1814
1936
  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 遷移"
@@ -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"),
@@ -49,6 +49,7 @@ from .models import (
49
49
  PveDriftEvent,
50
50
  PvePluginSettings,
51
51
  PveSyncJob,
52
+ PveVmTaskLog,
52
53
  PveWebhookEvent,
53
54
  VmProvisioningLog,
54
55
  )
@@ -58,6 +59,7 @@ from .tables import (
58
59
  PveClusterConfigTable,
59
60
  PveDriftEventTable,
60
61
  PveSyncJobTable,
62
+ PveVmTaskLogTable,
61
63
  PveWebhookEventTable,
62
64
  VmProvisioningLogTable,
63
65
  )
@@ -342,6 +344,25 @@ class PveDriftEventBulkDeleteView(generic.BulkDeleteView):
342
344
  table = PveDriftEventTable
343
345
 
344
346
 
347
+ # ============================================================================
348
+ # PveVmTaskLog — read-only list from PVE task history
349
+ # ============================================================================
350
+
351
+ class PveVmTaskLogListView(generic.ObjectListView):
352
+ queryset = PveVmTaskLog.objects.order_by("-start_time")
353
+ table = PveVmTaskLogTable
354
+ template_name = "pve_sync/pvevmtasklog_list.html"
355
+
356
+
357
+ class PveVmTaskLogDeleteView(generic.ObjectDeleteView):
358
+ queryset = PveVmTaskLog.objects.all()
359
+
360
+
361
+ class PveVmTaskLogBulkDeleteView(generic.BulkDeleteView):
362
+ queryset = PveVmTaskLog.objects.all()
363
+ table = PveVmTaskLogTable
364
+
365
+
345
366
  # ============================================================================
346
367
  # Plugin Settings (singleton)
347
368
  # ============================================================================
@@ -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.3"
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