proxsync 2.0.0__tar.gz → 2.0.2__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 (69) hide show
  1. {proxsync-2.0.0/proxsync.egg-info → proxsync-2.0.2}/PKG-INFO +12 -14
  2. {proxsync-2.0.0 → proxsync-2.0.2}/README.md +11 -13
  3. {proxsync-2.0.0 → proxsync-2.0.2/proxsync.egg-info}/PKG-INFO +12 -14
  4. {proxsync-2.0.0 → proxsync-2.0.2}/proxsync.egg-info/SOURCES.txt +3 -0
  5. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync.py +110 -34
  6. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/api/serializers.py +18 -0
  7. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/api/urls.py +2 -0
  8. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/api/views.py +7 -0
  9. proxsync-2.0.2/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +41 -0
  10. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/models.py +55 -0
  11. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/navigation.py +9 -0
  12. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/tables.py +23 -0
  13. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/tasks.py +6 -4
  14. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +77 -48
  15. proxsync-2.0.2/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +334 -0
  16. proxsync-2.0.2/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +114 -0
  17. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templatetags/pve_sync_tags.py +14 -0
  18. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/urls.py +12 -1
  19. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/views.py +262 -0
  20. {proxsync-2.0.0 → proxsync-2.0.2}/pyproject.toml +1 -1
  21. {proxsync-2.0.0 → proxsync-2.0.2}/MANIFEST.in +0 -0
  22. {proxsync-2.0.0 → proxsync-2.0.2}/config.py +0 -0
  23. {proxsync-2.0.0 → proxsync-2.0.2}/pbs_sync.py +0 -0
  24. {proxsync-2.0.0 → proxsync-2.0.2}/proxsync.egg-info/dependency_links.txt +0 -0
  25. {proxsync-2.0.0 → proxsync-2.0.2}/proxsync.egg-info/requires.txt +0 -0
  26. {proxsync-2.0.0 → proxsync-2.0.2}/proxsync.egg-info/top_level.txt +0 -0
  27. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/__init__.py +0 -0
  28. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/api/__init__.py +0 -0
  29. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/apps.py +0 -0
  30. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/choices.py +0 -0
  31. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/filtersets.py +0 -0
  32. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/forms.py +0 -0
  33. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/management/__init__.py +0 -0
  34. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/management/commands/__init__.py +0 -0
  35. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  36. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  37. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  38. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  39. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  40. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  41. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  42. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  43. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  44. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  45. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  46. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  47. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  48. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  49. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  50. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/migrations/__init__.py +0 -0
  51. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/signals.py +0 -0
  52. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/sync/__init__.py +0 -0
  53. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/sync/config_bridge.py +0 -0
  54. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/sync/engine.py +0 -0
  55. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/template_content.py +0 -0
  56. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  57. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  58. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  59. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  60. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  61. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  62. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  63. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  64. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/templatetags/__init__.py +0 -0
  65. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/utils.py +0 -0
  66. {proxsync-2.0.0 → proxsync-2.0.2}/pve_sync_plugin/view_helpers.py +0 -0
  67. {proxsync-2.0.0 → proxsync-2.0.2}/setup.cfg +0 -0
  68. {proxsync-2.0.0 → proxsync-2.0.2}/state_db.py +0 -0
  69. {proxsync-2.0.0 → proxsync-2.0.2}/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.0
3
+ Version: 2.0.2
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -114,7 +114,7 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
114
114
  ### 1. 安裝插件
115
115
 
116
116
  ```bash
117
- /opt/netbox/venv/bin/pip install -e /opt/proxsync
117
+ /opt/netbox/venv/bin/pip install proxsync
118
118
  ```
119
119
 
120
120
  ### 2. 啟用插件
@@ -125,14 +125,6 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
125
125
  PLUGINS = [
126
126
  "pve_sync_plugin",
127
127
  ]
128
-
129
- PLUGINS_CONFIG = {
130
- "pve_sync_plugin": {
131
- # 也可在 Web UI Settings 頁面設定,此處為預設值
132
- "netbox_url": "http://localhost:8001",
133
- "netbox_token": "your-netbox-api-token",
134
- },
135
- }
136
128
  ```
137
129
 
138
130
  ### 3. 建立資料表
@@ -149,12 +141,12 @@ cd /opt/netbox/netbox
149
141
  sudo systemctl restart netbox netbox-rq
150
142
  ```
151
143
 
152
- ### 5. 設定排程自動同步(推薦)
144
+ ### 5. 設定排程自動同步
153
145
 
154
- 在 root crontab 加入以下排程,每 15 分鐘檢查一次是否有到期的同步任務:
146
+ 在 crontab 加入以下排程,每 15 分鐘檢查一次是否有到期的同步任務:
155
147
 
156
148
  ```bash
157
- sudo crontab -e
149
+ crontab -e
158
150
  ```
159
151
 
160
152
  加入:
@@ -163,7 +155,13 @@ sudo crontab -e
163
155
  */15 * * * * cd /opt/netbox/netbox && /opt/netbox/venv/bin/python manage.py run_scheduled_syncs >> /var/log/netbox-pve-scheduled-sync.log 2>&1
164
156
  ```
165
157
 
166
- > 設定好 cron 後,只需在 Web UI 各叢集設定中選擇同步週期,系統即會自動執行。
158
+ ### 6. 在 Web UI 完成設定
159
+
160
+ 前往 `Plugins → Proxmox Sync → Settings`,填入 NetBox URL、API Token,以及 Telegram 通知設定(可選)。
161
+
162
+ 接著前往 `Plugins → Proxmox Sync → PVE Clusters → Add` 新增第一個 PVE 叢集連線。
163
+
164
+ > 設定好 cron 後,只需在各叢集設定中選擇同步週期,系統即會自動執行。
167
165
 
168
166
  ---
169
167
 
@@ -78,7 +78,7 @@
78
78
  ### 1. 安裝插件
79
79
 
80
80
  ```bash
81
- /opt/netbox/venv/bin/pip install -e /opt/proxsync
81
+ /opt/netbox/venv/bin/pip install proxsync
82
82
  ```
83
83
 
84
84
  ### 2. 啟用插件
@@ -89,14 +89,6 @@
89
89
  PLUGINS = [
90
90
  "pve_sync_plugin",
91
91
  ]
92
-
93
- PLUGINS_CONFIG = {
94
- "pve_sync_plugin": {
95
- # 也可在 Web UI Settings 頁面設定,此處為預設值
96
- "netbox_url": "http://localhost:8001",
97
- "netbox_token": "your-netbox-api-token",
98
- },
99
- }
100
92
  ```
101
93
 
102
94
  ### 3. 建立資料表
@@ -113,12 +105,12 @@ cd /opt/netbox/netbox
113
105
  sudo systemctl restart netbox netbox-rq
114
106
  ```
115
107
 
116
- ### 5. 設定排程自動同步(推薦)
108
+ ### 5. 設定排程自動同步
117
109
 
118
- 在 root crontab 加入以下排程,每 15 分鐘檢查一次是否有到期的同步任務:
110
+ 在 crontab 加入以下排程,每 15 分鐘檢查一次是否有到期的同步任務:
119
111
 
120
112
  ```bash
121
- sudo crontab -e
113
+ crontab -e
122
114
  ```
123
115
 
124
116
  加入:
@@ -127,7 +119,13 @@ sudo crontab -e
127
119
  */15 * * * * cd /opt/netbox/netbox && /opt/netbox/venv/bin/python manage.py run_scheduled_syncs >> /var/log/netbox-pve-scheduled-sync.log 2>&1
128
120
  ```
129
121
 
130
- > 設定好 cron 後,只需在 Web UI 各叢集設定中選擇同步週期,系統即會自動執行。
122
+ ### 6. 在 Web UI 完成設定
123
+
124
+ 前往 `Plugins → Proxmox Sync → Settings`,填入 NetBox URL、API Token,以及 Telegram 通知設定(可選)。
125
+
126
+ 接著前往 `Plugins → Proxmox Sync → PVE Clusters → Add` 新增第一個 PVE 叢集連線。
127
+
128
+ > 設定好 cron 後,只需在各叢集設定中選擇同步週期,系統即會自動執行。
131
129
 
132
130
  ---
133
131
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxsync
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -114,7 +114,7 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
114
114
  ### 1. 安裝插件
115
115
 
116
116
  ```bash
117
- /opt/netbox/venv/bin/pip install -e /opt/proxsync
117
+ /opt/netbox/venv/bin/pip install proxsync
118
118
  ```
119
119
 
120
120
  ### 2. 啟用插件
@@ -125,14 +125,6 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
125
125
  PLUGINS = [
126
126
  "pve_sync_plugin",
127
127
  ]
128
-
129
- PLUGINS_CONFIG = {
130
- "pve_sync_plugin": {
131
- # 也可在 Web UI Settings 頁面設定,此處為預設值
132
- "netbox_url": "http://localhost:8001",
133
- "netbox_token": "your-netbox-api-token",
134
- },
135
- }
136
128
  ```
137
129
 
138
130
  ### 3. 建立資料表
@@ -149,12 +141,12 @@ cd /opt/netbox/netbox
149
141
  sudo systemctl restart netbox netbox-rq
150
142
  ```
151
143
 
152
- ### 5. 設定排程自動同步(推薦)
144
+ ### 5. 設定排程自動同步
153
145
 
154
- 在 root crontab 加入以下排程,每 15 分鐘檢查一次是否有到期的同步任務:
146
+ 在 crontab 加入以下排程,每 15 分鐘檢查一次是否有到期的同步任務:
155
147
 
156
148
  ```bash
157
- sudo crontab -e
149
+ crontab -e
158
150
  ```
159
151
 
160
152
  加入:
@@ -163,7 +155,13 @@ sudo crontab -e
163
155
  */15 * * * * cd /opt/netbox/netbox && /opt/netbox/venv/bin/python manage.py run_scheduled_syncs >> /var/log/netbox-pve-scheduled-sync.log 2>&1
164
156
  ```
165
157
 
166
- > 設定好 cron 後,只需在 Web UI 各叢集設定中選擇同步週期,系統即會自動執行。
158
+ ### 6. 在 Web UI 完成設定
159
+
160
+ 前往 `Plugins → Proxmox Sync → Settings`,填入 NetBox URL、API Token,以及 Telegram 通知設定(可選)。
161
+
162
+ 接著前往 `Plugins → Proxmox Sync → PVE Clusters → Add` 新增第一個 PVE 叢集連線。
163
+
164
+ > 設定好 cron 後,只需在各叢集設定中選擇同步週期,系統即會自動執行。
167
165
 
168
166
  ---
169
167
 
@@ -46,6 +46,7 @@ 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
49
50
  pve_sync_plugin/migrations/__init__.py
50
51
  pve_sync_plugin/sync/__init__.py
51
52
  pve_sync_plugin/sync/config_bridge.py
@@ -58,6 +59,8 @@ pve_sync_plugin/templates/pve_sync/pvedriftevent.html
58
59
  pve_sync_plugin/templates/pve_sync/pvesyncjob.html
59
60
  pve_sync_plugin/templates/pve_sync/pvewebhookevent.html
60
61
  pve_sync_plugin/templates/pve_sync/settings.html
62
+ pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html
63
+ pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html
61
64
  pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html
62
65
  pve_sync_plugin/templatetags/__init__.py
63
66
  pve_sync_plugin/templatetags/pve_sync_tags.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,114 @@ 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:
1132
- return
1133
- deleted_vmids = set(self._known_vmids.keys()) - seen_vmids
1134
- if not deleted_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:
1135
1162
  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 記錄則跳過
1141
- 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:
1148
- 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 中移除。"
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')
1158
1181
  )
1159
- self.send_telegram_notification(del_msg)
1160
- self._write_drift_event(vm_name, vmid, 'vm_deleted', 'vmid', str(vmid), '', notified=True)
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()
1161
1237
 
1162
1238
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1163
1239
  current_tags: List[str], network_interfaces: List[Dict] = None,
@@ -1698,8 +1774,8 @@ class OptimizedPVEToNetBoxSync:
1698
1774
  seen_vmids.add(int(vm['vmid']))
1699
1775
  if self.process_virtual_machine(vm, device, cluster, force=node_is_new):
1700
1776
  success_count += 1
1701
- # 偵測本次同步消失的 VM(已從 PVE 刪除)
1702
- self.detect_deleted_vms(seen_vmids)
1777
+ # 偵測並清理本次同步消失的 VM(已從 PVE 刪除)
1778
+ self.detect_deleted_vms(seen_vmids, cluster_id=cluster['id'])
1703
1779
  print(f"\n虛擬機同步完成: {success_count}/{total_count} 個虛擬機")
1704
1780
  return success_count > 0, success_count, total_count
1705
1781
 
@@ -10,6 +10,7 @@ from pve_sync_plugin.models import (
10
10
  PvePluginSettings,
11
11
  PveSyncJob,
12
12
  PveWebhookEvent,
13
+ VmProvisioningLog,
13
14
  )
14
15
 
15
16
 
@@ -159,3 +160,20 @@ class PvePluginSettingsSerializer(NetBoxModelSerializer):
159
160
  "enable_backup_sync",
160
161
  ]
161
162
  # Never expose secrets via API
163
+
164
+
165
+ class VmProvisioningLogSerializer(NetBoxModelSerializer):
166
+ url = serializers.HyperlinkedIdentityField(
167
+ view_name="plugins-api:pve_sync_plugin-api:vmprovisioninglog-detail",
168
+ )
169
+
170
+ class Meta:
171
+ model = VmProvisioningLog
172
+ fields = [
173
+ "id", "url", "display",
174
+ "vm_name", "vmid", "cluster_name", "node", "os_type",
175
+ "cpu", "ram_gb", "disk_gb",
176
+ "management_ip", "management_gw", "internet_ip", "internet_gw",
177
+ "status", "notes", "checklist",
178
+ "created", "last_updated",
179
+ ]
@@ -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 VmProvisioningLogViewSet
12
13
 
13
14
  app_name = "pve_sync_plugin-api"
14
15
 
@@ -20,6 +21,7 @@ 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)
23
25
 
24
26
  urlpatterns = [
25
27
  path("webhook/", plugin_views.webhook_receiver, name="webhook"),
@@ -18,6 +18,7 @@ from pve_sync_plugin.models import (
18
18
  PvePluginSettings,
19
19
  PveSyncJob,
20
20
  PveWebhookEvent,
21
+ VmProvisioningLog,
21
22
  )
22
23
 
23
24
  from .serializers import (
@@ -26,6 +27,7 @@ from .serializers import (
26
27
  PvePluginSettingsSerializer,
27
28
  PveSyncJobSerializer,
28
29
  PveWebhookEventSerializer,
30
+ VmProvisioningLogSerializer,
29
31
  )
30
32
 
31
33
 
@@ -92,3 +94,8 @@ class PveBackupStatusViewSet(NetBoxModelViewSet):
92
94
  class PvePluginSettingsViewSet(NetBoxModelViewSet):
93
95
  queryset = PvePluginSettings.objects.all()
94
96
  serializer_class = PvePluginSettingsSerializer
97
+
98
+
99
+ class VmProvisioningLogViewSet(NetBoxModelViewSet):
100
+ queryset = VmProvisioningLog.objects.order_by("-created")
101
+ serializer_class = VmProvisioningLogSerializer
@@ -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
+ ]
@@ -93,6 +93,10 @@ class PveSyncJob(NetBoxModel):
93
93
  "partial": "warning",
94
94
  }.get(self.status, "secondary")
95
95
 
96
+ @property
97
+ def is_pbs_job(self):
98
+ return self.cluster_name.startswith("pbs:")
99
+
96
100
  @property
97
101
  def error_message(self):
98
102
  if not self.details:
@@ -459,3 +463,54 @@ class PbsServerConfig(NetBoxModel):
459
463
  host = host.split('://', 1)[1]
460
464
  host = host.split(':')[0]
461
465
  return host
466
+
467
+
468
+
469
+ class VmProvisioningLog(NetBoxModel):
470
+ """Planning record created in the VM Provisioning Planner before PVE sync."""
471
+
472
+ STATUS_CHOICES = [
473
+ ("planning", "規劃中"),
474
+ ("in_progress", "部署中"),
475
+ ("completed", "完成"),
476
+ ]
477
+
478
+ vm_name = models.CharField(max_length=100, verbose_name="VM 名稱")
479
+ vmid = models.IntegerField(null=True, blank=True, verbose_name="VMID")
480
+ cluster_name = models.CharField(max_length=100, blank=True, verbose_name="叢集")
481
+ node = models.CharField(max_length=100, blank=True, verbose_name="節點")
482
+ os_type = models.CharField(max_length=50, blank=True, verbose_name="作業系統")
483
+ cpu = models.IntegerField(null=True, blank=True, verbose_name="CPU")
484
+ ram_gb = models.IntegerField(null=True, blank=True, verbose_name="記憶體 (GB)")
485
+ disk_gb = models.IntegerField(null=True, blank=True, verbose_name="磁碟 (GB)")
486
+ management_ip = models.CharField(max_length=50, blank=True, verbose_name="Management IP")
487
+ management_gw = models.CharField(max_length=50, blank=True, verbose_name="Management 閘道")
488
+ internet_ip = models.CharField(max_length=50, blank=True, verbose_name="Internet IP")
489
+ internet_gw = models.CharField(max_length=50, blank=True, verbose_name="Internet 閘道")
490
+ status = models.CharField(max_length=20, default="planning",
491
+ choices=STATUS_CHOICES, verbose_name="狀態")
492
+ notes = models.TextField(blank=True, verbose_name="備註")
493
+ checklist = models.JSONField(default=dict, blank=True, verbose_name="清單狀態")
494
+
495
+ class Meta:
496
+ ordering = ["-created"]
497
+ verbose_name = "VM Provisioning Log"
498
+
499
+ def __str__(self):
500
+ vmid_str = f" (VMID {self.vmid})" if self.vmid else ""
501
+ return f"{self.vm_name}{vmid_str}"
502
+
503
+ def get_absolute_url(self):
504
+ return reverse("plugins:pve_sync_plugin:vmprovisioninglog", args=[self.pk])
505
+
506
+ @property
507
+ def status_badge(self):
508
+ return {"planning": "info", "in_progress": "warning", "completed": "success"}.get(
509
+ self.status, "secondary"
510
+ )
511
+
512
+ QEMU_GA_ITEMS = [
513
+ ("chk_qemu_options", "在 VM Options 啟用 QEMU Guest Agent"),
514
+ ("chk_qemu_install", "安裝 qemu-guest-agent 套件"),
515
+ ("chk_qemu_enable", "啟用並啟動 qemu-guest-agent 服務"),
516
+ ]
@@ -70,6 +70,11 @@ pbs_item = PluginMenuItem(
70
70
  )
71
71
 
72
72
 
73
+ vm_provisioning_item = PluginMenuItem(
74
+ link="plugins:pve_sync_plugin:vmprovisioninglog_list",
75
+ link_text="VM 佈建",
76
+ )
77
+
73
78
  menu = PluginMenu(
74
79
  label="Proxmox Sync",
75
80
  groups=(
@@ -85,6 +90,10 @@ menu = PluginMenu(
85
90
  "Data Protection",
86
91
  (backup_status_item,),
87
92
  ),
93
+ (
94
+ "VM Operations",
95
+ (vm_provisioning_item,),
96
+ ),
88
97
  (
89
98
  "Configuration",
90
99
  (clusters_item, pbs_item, settings_item),
@@ -11,6 +11,7 @@ from .models import (
11
11
  PveDriftEvent,
12
12
  PveSyncJob,
13
13
  PveWebhookEvent,
14
+ VmProvisioningLog,
14
15
  )
15
16
 
16
17
 
@@ -276,3 +277,25 @@ class PveBackupStatusTable(NetBoxTable):
276
277
  "last_backup",
277
278
  "backup_age_days",
278
279
  )
280
+
281
+
282
+ # ---------------------------------------------------------------------------
283
+ # VmProvisioningLog
284
+ # ---------------------------------------------------------------------------
285
+
286
+ class VmProvisioningLogTable(NetBoxTable):
287
+ vm_name = tables.Column(linkify=True, verbose_name="VM 名稱")
288
+ vmid = tables.Column(verbose_name="VMID")
289
+ cluster_name = tables.Column(verbose_name="叢集")
290
+ node = tables.Column(verbose_name="節點")
291
+ status = columns.ChoiceFieldColumn(verbose_name="狀態")
292
+ management_ip = tables.Column(verbose_name="Management IP")
293
+ internet_ip = tables.Column(verbose_name="Internet IP")
294
+ actions = columns.ActionsColumn(actions=("delete",))
295
+
296
+ class Meta(NetBoxTable.Meta):
297
+ model = VmProvisioningLog
298
+ fields = ("pk", "vm_name", "vmid", "cluster_name", "node", "status",
299
+ "management_ip", "internet_ip", "created")
300
+ default_columns = ("vm_name", "vmid", "cluster_name", "node", "status",
301
+ "management_ip", "internet_ip")
@@ -252,18 +252,20 @@ def run_pbs_sync_job(job_id, pbs_server_pk):
252
252
  syncer.sync()
253
253
 
254
254
  # Write backup records to PveBackupStatus
255
+ backup_updated, backup_skipped = None, None
255
256
  try:
256
257
  backup_records = _fetch_pbs_snapshots(pbs)
257
- updated, skipped = _apply_pbs_backup_status(backup_records)
258
- job.details["backup_updated"] = updated
259
- job.details["backup_skipped"] = skipped
260
- logger.info("PBS backup status: %d updated, %d no VM match", updated, skipped)
258
+ backup_updated, backup_skipped = _apply_pbs_backup_status(backup_records)
259
+ logger.info("PBS backup status: %d updated, %d no VM match", backup_updated, backup_skipped)
261
260
  except Exception as exc:
262
261
  logger.warning("PBS backup status sync failed (non-fatal): %s", exc)
263
262
 
264
263
  job.refresh_from_db()
265
264
  job.status = "success"
266
265
  job.end_time = timezone.now()
266
+ if backup_updated is not None:
267
+ job.details["backup_updated"] = backup_updated
268
+ job.details["backup_skipped"] = backup_skipped
267
269
  job.details.pop("heartbeat", None)
268
270
  job.save()
269
271