proxsync 2.0.1__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.1/proxsync.egg-info → proxsync-2.0.3}/PKG-INFO +1 -1
  2. {proxsync-2.0.1 → proxsync-2.0.3/proxsync.egg-info}/PKG-INFO +1 -1
  3. {proxsync-2.0.1 → proxsync-2.0.3}/proxsync.egg-info/SOURCES.txt +7 -0
  4. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync.py +231 -33
  5. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/api/serializers.py +35 -0
  6. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/api/urls.py +3 -0
  7. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/api/views.py +14 -0
  8. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/choices.py +13 -0
  9. proxsync-2.0.3/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +41 -0
  10. proxsync-2.0.3/pve_sync_plugin/migrations/0015_pvevmtasklog.py +55 -0
  11. proxsync-2.0.3/pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py +18 -0
  12. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/models.py +108 -0
  13. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/navigation.py +14 -0
  14. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/tables.py +48 -0
  15. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/template_content.py +35 -2
  16. proxsync-2.0.3/pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html +57 -0
  17. proxsync-2.0.3/pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html +10 -0
  18. proxsync-2.0.3/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +334 -0
  19. proxsync-2.0.3/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +114 -0
  20. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templatetags/pve_sync_tags.py +14 -0
  21. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/urls.py +19 -1
  22. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/views.py +283 -0
  23. {proxsync-2.0.1 → proxsync-2.0.3}/pyproject.toml +1 -1
  24. {proxsync-2.0.1 → proxsync-2.0.3}/MANIFEST.in +0 -0
  25. {proxsync-2.0.1 → proxsync-2.0.3}/README.md +0 -0
  26. {proxsync-2.0.1 → proxsync-2.0.3}/config.py +0 -0
  27. {proxsync-2.0.1 → proxsync-2.0.3}/pbs_sync.py +0 -0
  28. {proxsync-2.0.1 → proxsync-2.0.3}/proxsync.egg-info/dependency_links.txt +0 -0
  29. {proxsync-2.0.1 → proxsync-2.0.3}/proxsync.egg-info/requires.txt +0 -0
  30. {proxsync-2.0.1 → proxsync-2.0.3}/proxsync.egg-info/top_level.txt +0 -0
  31. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/__init__.py +0 -0
  32. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/api/__init__.py +0 -0
  33. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/apps.py +0 -0
  34. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/filtersets.py +0 -0
  35. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/forms.py +0 -0
  36. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/management/__init__.py +0 -0
  37. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/management/commands/__init__.py +0 -0
  38. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  39. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  40. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  41. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  42. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  43. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  44. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  45. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  46. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  47. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  48. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  49. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  50. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  51. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  52. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  53. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/migrations/__init__.py +0 -0
  54. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/signals.py +0 -0
  55. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/sync/__init__.py +0 -0
  56. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/sync/config_bridge.py +0 -0
  57. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/sync/engine.py +0 -0
  58. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/tasks.py +0 -0
  59. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  60. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  61. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  62. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  63. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  64. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  65. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +0 -0
  66. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  67. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  68. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/templatetags/__init__.py +0 -0
  69. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/utils.py +0 -0
  70. {proxsync-2.0.1 → proxsync-2.0.3}/pve_sync_plugin/view_helpers.py +0 -0
  71. {proxsync-2.0.1 → proxsync-2.0.3}/setup.cfg +0 -0
  72. {proxsync-2.0.1 → proxsync-2.0.3}/state_db.py +0 -0
  73. {proxsync-2.0.1 → 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.1
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.1
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
@@ -46,6 +46,9 @@ pve_sync_plugin/migrations/0010_pvedriftevent.py
46
46
  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
+ 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
49
52
  pve_sync_plugin/migrations/__init__.py
50
53
  pve_sync_plugin/sync/__init__.py
51
54
  pve_sync_plugin/sync/config_bridge.py
@@ -56,9 +59,13 @@ pve_sync_plugin/templates/pve_sync/pvebackupstatus.html
56
59
  pve_sync_plugin/templates/pve_sync/pveclusterconfig.html
57
60
  pve_sync_plugin/templates/pve_sync/pvedriftevent.html
58
61
  pve_sync_plugin/templates/pve_sync/pvesyncjob.html
62
+ pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html
59
63
  pve_sync_plugin/templates/pve_sync/pvewebhookevent.html
60
64
  pve_sync_plugin/templates/pve_sync/settings.html
65
+ pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html
66
+ pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html
61
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
62
69
  pve_sync_plugin/templatetags/__init__.py
63
70
  pve_sync_plugin/templatetags/pve_sync_tags.py
64
71
  tests/test_plugin_contracts.py
@@ -119,6 +119,7 @@ class OptimizedPVEToNetBoxSync:
119
119
  'vms_by_node': {},
120
120
  'pools': {},
121
121
  'node_networks': {},
122
+ 'failed_nodes': set(), # nodes where VM API call failed — skip cleanup for these
122
123
  }
123
124
 
124
125
  self.error_log = []
@@ -361,6 +362,7 @@ class OptimizedPVEToNetBoxSync:
361
362
  except Exception as e:
362
363
  print(f" 加載節點 {node_name} 的虛擬機失敗: {e}")
363
364
  self.pve_cache['vms_by_node'][node_name] = []
365
+ self.pve_cache['failed_nodes'].add(node_name.lower())
364
366
  elapsed = time.time() - start_time
365
367
  print(f"✓ PVE 數據加載完成,耗時 {elapsed:.2f} 秒")
366
368
  print(f" 節點: {len(self.pve_cache['nodes'])} 個")
@@ -1124,40 +1126,228 @@ class OptimizedPVEToNetBoxSync:
1124
1126
  self.send_telegram_notification(new_msg)
1125
1127
  self._write_drift_event(vm_name, vm_id, 'vm_created', 'vmid', '', str(vm_id), notified=True)
1126
1128
 
1127
- def detect_deleted_vms(self, seen_vmids: set):
1128
- """比對先前已知 vmid 與本次同步的 vmid,偵測被刪除的 VM。"""
1129
- if not self.enhanced_mode or not self.state_db:
1130
- return
1131
- if not self._known_vmids:
1129
+ def detect_deleted_vms(self, seen_vmids: set, cluster_id: int = None):
1130
+ """偵測並清理 NetBox 中已從 PVE 刪除的 VM(含 IP)。"""
1131
+
1132
+ # ── 1. 舊有:drift 通知(依賴 state_db)──
1133
+ if self.enhanced_mode and self.state_db and self._known_vmids:
1134
+ deleted_vmids = set(self._known_vmids.keys()) - seen_vmids
1135
+ for vmid in deleted_vmids:
1136
+ info = self._known_vmids[vmid]
1137
+ vm_name = info.get('vm_name') or f"VM-{vmid}"
1138
+ node_name = info.get('node') or 'unknown'
1139
+ try:
1140
+ from pve_sync_plugin.models import PveDriftEvent
1141
+ if PveDriftEvent.objects.filter(
1142
+ vmid=vmid, cluster_name=self.cluster_name, drift_type='vm_deleted'
1143
+ ).exists():
1144
+ continue
1145
+ except Exception:
1146
+ pass
1147
+ print(f"🗑️ VM {vm_name} 刪除偵測 (ID: {vmid}, 上次節點: {node_name})")
1148
+ self.stats['config_drifts_detected'] += 1
1149
+ del_msg = (
1150
+ f"🗑️ <b>VM 刪除偵測</b>\n\n"
1151
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vmid})\n"
1152
+ f"🔀 叢集: {self.cluster_name}\n"
1153
+ f"📌 上次節點: <code>{node_name}</code>\n"
1154
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1155
+ f"⚠️ 此 VM 已從 Proxmox 中移除。"
1156
+ )
1157
+ self.send_telegram_notification(del_msg)
1158
+ self._write_drift_event(vm_name, vmid, 'vm_deleted', 'vmid', str(vmid), '', notified=True)
1159
+
1160
+ # ── 2. 主動清理:刪除 NetBox 中已不存在於 PVE 的 VM 及其 IP ──
1161
+ if not cluster_id:
1132
1162
  return
1133
- deleted_vmids = set(self._known_vmids.keys()) - seen_vmids
1134
- if not deleted_vmids:
1163
+
1164
+ failed_nodes = self.pve_cache.get('failed_nodes', set())
1165
+ if failed_nodes:
1166
+ print(f"⚠️ 節點 API 失敗,跳過其 VM 清理: {failed_nodes}")
1167
+
1168
+ try:
1169
+ from django.contrib.contenttypes.models import ContentType
1170
+ from virtualization.models import VirtualMachine, VMInterface
1171
+ from ipam.models import IPAddress
1172
+
1173
+ seen_serials = {str(v) for v in seen_vmids}
1174
+ stale_vms = list(
1175
+ VirtualMachine.objects
1176
+ .filter(cluster_id=cluster_id)
1177
+ .exclude(serial='')
1178
+ .exclude(serial__isnull=True)
1179
+ .exclude(serial__in=seen_serials)
1180
+ .select_related('device')
1181
+ )
1182
+
1183
+ if not stale_vms:
1184
+ return
1185
+
1186
+ from pve_sync_plugin.models import PveDriftEvent
1187
+
1188
+ vm_ct = ContentType.objects.get_for_model(VMInterface)
1189
+ for vm in stale_vms:
1190
+ device_name = vm.device.name.lower() if vm.device else None
1191
+ if device_name and device_name in failed_nodes:
1192
+ print(f" ⏭️ 跳過 {vm.name} (VMID {vm.serial}):節點 {vm.device.name} API 失敗")
1193
+ continue
1194
+
1195
+ vm_name_str = vm.name
1196
+ vmid_int = int(vm.serial)
1197
+
1198
+ iface_ids = list(
1199
+ VMInterface.objects.filter(virtual_machine=vm).values_list('id', flat=True)
1200
+ )
1201
+ ip_deleted = 0
1202
+ if iface_ids:
1203
+ ip_deleted, _ = IPAddress.objects.filter(
1204
+ assigned_object_type=vm_ct,
1205
+ assigned_object_id__in=iface_ids,
1206
+ ).delete()
1207
+
1208
+ vm.delete()
1209
+ print(f" 🗑️ 已刪除 VM {vm_name_str} (VMID {vmid_int}) 及 {ip_deleted} 個 IP")
1210
+ self.stats['config_drifts_detected'] += 1
1211
+
1212
+ # 寫入漂移事件(避免與 section 1 重複)
1213
+ already_logged = PveDriftEvent.objects.filter(
1214
+ vmid=vmid_int,
1215
+ cluster_name=self.cluster_name,
1216
+ drift_type='vm_deleted',
1217
+ ).exists()
1218
+ if not already_logged:
1219
+ self._write_drift_event(
1220
+ vm_name_str, vmid_int, 'vm_deleted', 'vmid',
1221
+ str(vmid_int), '', notified=False,
1222
+ )
1223
+ del_msg = (
1224
+ f"🗑️ <b>VM 已從 NetBox 清除</b>\n\n"
1225
+ f"🖥️ 名稱: <b>{vm_name_str}</b> (VMID: {vmid_int})\n"
1226
+ f"🔀 叢集: {self.cluster_name}\n"
1227
+ f"🧹 已清除 IP 記錄: {ip_deleted} 個\n"
1228
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1229
+ f"⚠️ 此 VM 已從 Proxmox 刪除,NetBox 記錄已自動移除。"
1230
+ )
1231
+ self.send_telegram_notification(del_msg)
1232
+
1233
+ except Exception as exc:
1234
+ import traceback
1235
+ print(f"⚠️ 清理孤兒 VM 失敗: {exc}")
1236
+ traceback.print_exc()
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:
1135
1244
  return
1136
- for vmid in deleted_vmids:
1137
- info = self._known_vmids[vmid]
1138
- vm_name = info.get('vm_name') or f"VM-{vmid}"
1139
- node_name = info.get('node') or 'unknown'
1140
- # 避免重複通知:若已有此 vmid 的 vm_deleted 記錄則跳過
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():
1141
1266
  try:
1142
- from pve_sync_plugin.models import PveDriftEvent
1143
- if PveDriftEvent.objects.filter(
1144
- vmid=vmid, cluster_name=self.cluster_name, drift_type='vm_deleted'
1145
- ).exists():
1146
- continue
1147
- except Exception:
1267
+ vmid_to_name[int(vm.serial)] = vm.name
1268
+ except (ValueError, TypeError, AttributeError):
1148
1269
  pass
1149
- print(f"🗑️ VM {vm_name} 刪除偵測 (ID: {vmid}, 上次節點: {node_name})")
1150
- self.stats['config_drifts_detected'] += 1
1151
- del_msg = (
1152
- f"🗑️ <b>VM 刪除偵測</b>\n\n"
1153
- f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vmid})\n"
1154
- f"🔀 叢集: {self.cluster_name}\n"
1155
- f"📌 上次節點: <code>{node_name}</code>\n"
1156
- f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1157
- f"⚠️ 此 VM 已從 Proxmox 中移除。"
1158
- )
1159
- self.send_telegram_notification(del_msg)
1160
- self._write_drift_event(vm_name, vmid, 'vm_deleted', 'vmid', str(vmid), '', notified=True)
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 操作記錄")
1161
1351
 
1162
1352
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1163
1353
  current_tags: List[str], network_interfaces: List[Dict] = None,
@@ -1500,14 +1690,18 @@ class OptimizedPVEToNetBoxSync:
1500
1690
  # 但 state_db 仍有舊 hash 導致增量跳過。強制往下完整同步。
1501
1691
  print(f" ⚠️ VM {original_vm_name} 不在 NetBox 中,忽略增量記錄強制建立")
1502
1692
  else:
1503
- # VM 存在;確保 device 關聯指向正確節點
1693
+ # VM 存在;確保 device 關聯、cluster 關聯、電源狀態都是最新的
1504
1694
  current_device_id = getattr(getattr(cached_vm, 'device', None), 'id', None)
1505
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', ''))
1506
1698
  fix = {}
1507
1699
  if current_device_id != device.id:
1508
1700
  fix['device'] = device.id
1509
1701
  if current_cluster_id != cluster['id']:
1510
1702
  fix['cluster'] = cluster['id']
1703
+ if current_status != status:
1704
+ fix['status'] = status
1511
1705
  if fix:
1512
1706
  try:
1513
1707
  cached_vm.update(fix)
@@ -1515,6 +1709,8 @@ class OptimizedPVEToNetBoxSync:
1515
1709
  print(f" ✓ VM {original_vm_name} 叢集關聯已修正: → {cluster['name']}")
1516
1710
  if 'device' in fix:
1517
1711
  print(f" ✓ VM {original_vm_name} 設備關聯已修正: → {device.name}")
1712
+ if 'status' in fix:
1713
+ print(f" ✓ VM {original_vm_name} 電源狀態更新: {current_status} → {status}")
1518
1714
  except Exception as e:
1519
1715
  print(f" ✗ 更新 VM {original_vm_name} 關聯失敗: {e}")
1520
1716
  print(f" ℹ️ VM {original_vm_name} 無配置變更,跳過同步")
@@ -1698,8 +1894,8 @@ class OptimizedPVEToNetBoxSync:
1698
1894
  seen_vmids.add(int(vm['vmid']))
1699
1895
  if self.process_virtual_machine(vm, device, cluster, force=node_is_new):
1700
1896
  success_count += 1
1701
- # 偵測本次同步消失的 VM(已從 PVE 刪除)
1702
- self.detect_deleted_vms(seen_vmids)
1897
+ # 偵測並清理本次同步消失的 VM(已從 PVE 刪除)
1898
+ self.detect_deleted_vms(seen_vmids, cluster_id=cluster['id'])
1703
1899
  print(f"\n虛擬機同步完成: {success_count}/{total_count} 個虛擬機")
1704
1900
  return success_count > 0, success_count, total_count
1705
1901
 
@@ -1733,6 +1929,8 @@ class OptimizedPVEToNetBoxSync:
1733
1929
  print("\n" + "=" * 50)
1734
1930
  print("✓ 節點同步成功")
1735
1931
  vms_success, success_count, total_count = self.sync_pve_virtual_machines(devices, cluster)
1932
+ print("\n同步 VM 操作記錄...")
1933
+ self.sync_vm_tasks()
1736
1934
  elapsed = time.time() - start_time
1737
1935
  self.stats['elapsed_time'] = elapsed
1738
1936
  self.stats['total_vms'] = total_count
@@ -9,7 +9,9 @@ from pve_sync_plugin.models import (
9
9
  PveClusterConfig,
10
10
  PvePluginSettings,
11
11
  PveSyncJob,
12
+ PveVmTaskLog,
12
13
  PveWebhookEvent,
14
+ VmProvisioningLog,
13
15
  )
14
16
 
15
17
 
@@ -159,3 +161,36 @@ class PvePluginSettingsSerializer(NetBoxModelSerializer):
159
161
  "enable_backup_sync",
160
162
  ]
161
163
  # Never expose secrets via API
164
+
165
+
166
+ class VmProvisioningLogSerializer(NetBoxModelSerializer):
167
+ url = serializers.HyperlinkedIdentityField(
168
+ view_name="plugins-api:pve_sync_plugin-api:vmprovisioninglog-detail",
169
+ )
170
+
171
+ class Meta:
172
+ model = VmProvisioningLog
173
+ fields = [
174
+ "id", "url", "display",
175
+ "vm_name", "vmid", "cluster_name", "node", "os_type",
176
+ "cpu", "ram_gb", "disk_gb",
177
+ "management_ip", "management_gw", "internet_ip", "internet_gw",
178
+ "status", "notes", "checklist",
179
+ "created", "last_updated",
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,6 +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 PveVmTaskLogViewSet, VmProvisioningLogViewSet
12
13
 
13
14
  app_name = "pve_sync_plugin-api"
14
15
 
@@ -20,6 +21,8 @@ router.register("events", api_views.PveWebhookEventViewSet)
20
21
  router.register("clusters", api_views.PveClusterConfigViewSet)
21
22
  router.register("backup-status", api_views.PveBackupStatusViewSet)
22
23
  router.register("settings", api_views.PvePluginSettingsViewSet)
24
+ router.register("provisioning", api_views.VmProvisioningLogViewSet)
25
+ router.register("task-logs", api_views.PveVmTaskLogViewSet)
23
26
 
24
27
  urlpatterns = [
25
28
  path("webhook/", plugin_views.webhook_receiver, name="webhook"),
@@ -17,7 +17,9 @@ from pve_sync_plugin.models import (
17
17
  PveClusterConfig,
18
18
  PvePluginSettings,
19
19
  PveSyncJob,
20
+ PveVmTaskLog,
20
21
  PveWebhookEvent,
22
+ VmProvisioningLog,
21
23
  )
22
24
 
23
25
  from .serializers import (
@@ -25,7 +27,9 @@ from .serializers import (
25
27
  PveClusterConfigSerializer,
26
28
  PvePluginSettingsSerializer,
27
29
  PveSyncJobSerializer,
30
+ PveVmTaskLogSerializer,
28
31
  PveWebhookEventSerializer,
32
+ VmProvisioningLogSerializer,
29
33
  )
30
34
 
31
35
 
@@ -92,3 +96,13 @@ class PveBackupStatusViewSet(NetBoxModelViewSet):
92
96
  class PvePluginSettingsViewSet(NetBoxModelViewSet):
93
97
  queryset = PvePluginSettings.objects.all()
94
98
  serializer_class = PvePluginSettingsSerializer
99
+
100
+
101
+ class VmProvisioningLogViewSet(NetBoxModelViewSet):
102
+ queryset = VmProvisioningLog.objects.order_by("-created")
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,41 @@
1
+ from django.db import migrations, models
2
+ import django.db.models.deletion
3
+
4
+
5
+ class Migration(migrations.Migration):
6
+
7
+ dependencies = [
8
+ ("pve_sync_plugin", "0013_pluginsettings_log_retention"),
9
+ ("extras", "0138_customfieldchoiceset_choice_colors"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="VmProvisioningLog",
15
+ fields=[
16
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
17
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
18
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
19
+ ("custom_field_data", models.JSONField(blank=True, default=dict, encoder=None)),
20
+ ("vm_name", models.CharField(max_length=100, verbose_name="VM 名稱")),
21
+ ("vmid", models.IntegerField(blank=True, null=True, verbose_name="VMID")),
22
+ ("cluster_name", models.CharField(blank=True, max_length=100, verbose_name="叢集")),
23
+ ("node", models.CharField(blank=True, max_length=100, verbose_name="節點")),
24
+ ("os_type", models.CharField(blank=True, max_length=50, verbose_name="作業系統")),
25
+ ("cpu", models.IntegerField(blank=True, null=True, verbose_name="CPU")),
26
+ ("ram_gb", models.IntegerField(blank=True, null=True, verbose_name="記憶體 (GB)")),
27
+ ("disk_gb", models.IntegerField(blank=True, null=True, verbose_name="磁碟 (GB)")),
28
+ ("management_ip", models.CharField(blank=True, max_length=50, verbose_name="Management IP")),
29
+ ("management_gw", models.CharField(blank=True, max_length=50, verbose_name="Management 閘道")),
30
+ ("internet_ip", models.CharField(blank=True, max_length=50, verbose_name="Internet IP")),
31
+ ("internet_gw", models.CharField(blank=True, max_length=50, verbose_name="Internet 閘道")),
32
+ ("status", models.CharField(
33
+ choices=[("planning", "規劃中"), ("in_progress", "部署中"), ("completed", "完成")],
34
+ default="planning", max_length=20, verbose_name="狀態",
35
+ )),
36
+ ("notes", models.TextField(blank=True, verbose_name="備註")),
37
+ ("checklist", models.JSONField(blank=True, default=dict, verbose_name="清單狀態")),
38
+ ],
39
+ options={"ordering": ["-created"], "verbose_name": "VM Provisioning Log"},
40
+ ),
41
+ ]
@@ -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
+ ]