proxsync 2.1.0__tar.gz → 2.1.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 (73) hide show
  1. {proxsync-2.1.0/proxsync.egg-info → proxsync-2.1.2}/PKG-INFO +47 -2
  2. {proxsync-2.1.0 → proxsync-2.1.2}/README.md +46 -1
  3. {proxsync-2.1.0 → proxsync-2.1.2/proxsync.egg-info}/PKG-INFO +47 -2
  4. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync.py +180 -37
  5. {proxsync-2.1.0 → proxsync-2.1.2}/pyproject.toml +1 -1
  6. {proxsync-2.1.0 → proxsync-2.1.2}/state_db.py +24 -2
  7. {proxsync-2.1.0 → proxsync-2.1.2}/MANIFEST.in +0 -0
  8. {proxsync-2.1.0 → proxsync-2.1.2}/config.py +0 -0
  9. {proxsync-2.1.0 → proxsync-2.1.2}/pbs_sync.py +0 -0
  10. {proxsync-2.1.0 → proxsync-2.1.2}/proxsync.egg-info/SOURCES.txt +0 -0
  11. {proxsync-2.1.0 → proxsync-2.1.2}/proxsync.egg-info/dependency_links.txt +0 -0
  12. {proxsync-2.1.0 → proxsync-2.1.2}/proxsync.egg-info/requires.txt +0 -0
  13. {proxsync-2.1.0 → proxsync-2.1.2}/proxsync.egg-info/top_level.txt +0 -0
  14. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/__init__.py +0 -0
  15. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/api/__init__.py +0 -0
  16. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/api/serializers.py +0 -0
  17. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/api/urls.py +0 -0
  18. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/api/views.py +0 -0
  19. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/apps.py +0 -0
  20. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/choices.py +0 -0
  21. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/filtersets.py +0 -0
  22. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/forms.py +0 -0
  23. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/management/__init__.py +0 -0
  24. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/management/commands/__init__.py +0 -0
  25. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  26. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  27. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  28. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  29. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  30. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  31. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  32. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  33. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  34. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  35. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  36. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  37. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  38. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  39. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  40. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +0 -0
  41. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0015_pvevmtasklog.py +0 -0
  42. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py +0 -0
  43. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/migrations/__init__.py +0 -0
  44. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/models.py +0 -0
  45. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/navigation.py +0 -0
  46. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/signals.py +0 -0
  47. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/sync/__init__.py +0 -0
  48. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/sync/config_bridge.py +0 -0
  49. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/sync/engine.py +0 -0
  50. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/tables.py +0 -0
  51. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/tasks.py +0 -0
  52. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/template_content.py +0 -0
  53. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  54. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  55. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html +0 -0
  56. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  57. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  58. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  59. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  60. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +0 -0
  61. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html +0 -0
  62. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  63. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  64. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +0 -0
  65. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +0 -0
  66. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templatetags/__init__.py +0 -0
  67. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/templatetags/pve_sync_tags.py +0 -0
  68. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/urls.py +0 -0
  69. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/utils.py +0 -0
  70. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/view_helpers.py +0 -0
  71. {proxsync-2.1.0 → proxsync-2.1.2}/pve_sync_plugin/views.py +0 -0
  72. {proxsync-2.1.0 → proxsync-2.1.2}/setup.cfg +0 -0
  73. {proxsync-2.1.0 → proxsync-2.1.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.1.0
3
+ Version: 2.1.2
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -36,7 +36,7 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
36
36
 
37
37
  # proxsync
38
38
 
39
- [![Version](https://img.shields.io/badge/version-2.1.0-blue.svg)](https://github.com/XiuJie2/proxsync)
39
+ [![Version](https://img.shields.io/badge/version-2.1.1-blue.svg)](https://github.com/XiuJie2/proxsync)
40
40
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/)
41
41
  [![NetBox 4.5–4.6](https://img.shields.io/badge/NetBox-4.5%20–%204.6-green.svg)](https://netbox.dev/)
42
42
  [![Proxmox VE](https://img.shields.io/badge/Proxmox-VE%20%2B%20PBS-orange.svg)](https://www.proxmox.com/)
@@ -46,6 +46,49 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
46
46
 
47
47
  ---
48
48
 
49
+ ## 目錄
50
+
51
+ - [專案截圖](#專案截圖)
52
+ - [功能特色](#功能特色)
53
+ - [系統需求](#系統需求)
54
+ - [安裝](#安裝)
55
+ - [設定](#設定)
56
+ - [使用方式](#使用方式)
57
+ - [資料對應](#資料對應)
58
+ - [專案結構](#專案結構)
59
+ - [設定優先級](#設定優先級)
60
+ - [常見問題](#常見問題)
61
+ - [開發](#開發)
62
+ - [授權](#授權)
63
+ - [致謝](#致謝)
64
+
65
+ ---
66
+
67
+ ## 專案截圖
68
+
69
+ <!--
70
+ 將截圖放進 docs/screenshots/ 目錄,檔名對應下方即可自動顯示。
71
+ 建議尺寸:1600x900 或以上,PNG 格式。
72
+ -->
73
+
74
+ ### Dashboard
75
+
76
+ ![Dashboard](docs/screenshots/dashboard.png)
77
+
78
+ ### PVE Cluster 詳情
79
+
80
+ ![PVE Cluster](docs/screenshots/pve-cluster.png)
81
+
82
+ ### Sync Jobs 同步歷史
83
+
84
+ ![Sync Jobs](docs/screenshots/sync-jobs.png)
85
+
86
+ ### Telegram 通知
87
+
88
+ ![Telegram Notification](docs/screenshots/telegram-notification.png)
89
+
90
+ ---
91
+
49
92
  ## 功能特色
50
93
 
51
94
  ### Proxmox VE 同步
@@ -54,6 +97,8 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
54
97
  - VM / CT → NetBox **Virtual Machine**(狀態、規格、磁碟、標籤)
55
98
  - QEMU Guest Agent 整合,從執行中 VM 取得實際 IP 位址
56
99
  - 配置漂移偵測:自動發現 VM 的 CPU / 記憶體設定變更
100
+ - 節點放置檢查:VM 描述 (Notes) 內若標記 `node: <預期節點>`,當實際所在節點與標記不符時透過 Telegram 提醒
101
+ - Guest Agent 異常提醒:VM 執行中卻取不到 IP(QEMU Guest Agent 未啟用或未安裝)時透過 Telegram 提醒
57
102
  - 增量同步:透過 SQLite state_db 追蹤變更,避免全量更新
58
103
 
59
104
  ### 多叢集支援
@@ -1,6 +1,6 @@
1
1
  # proxsync
2
2
 
3
- [![Version](https://img.shields.io/badge/version-2.1.0-blue.svg)](https://github.com/XiuJie2/proxsync)
3
+ [![Version](https://img.shields.io/badge/version-2.1.1-blue.svg)](https://github.com/XiuJie2/proxsync)
4
4
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/)
5
5
  [![NetBox 4.5–4.6](https://img.shields.io/badge/NetBox-4.5%20–%204.6-green.svg)](https://netbox.dev/)
6
6
  [![Proxmox VE](https://img.shields.io/badge/Proxmox-VE%20%2B%20PBS-orange.svg)](https://www.proxmox.com/)
@@ -10,6 +10,49 @@
10
10
 
11
11
  ---
12
12
 
13
+ ## 目錄
14
+
15
+ - [專案截圖](#專案截圖)
16
+ - [功能特色](#功能特色)
17
+ - [系統需求](#系統需求)
18
+ - [安裝](#安裝)
19
+ - [設定](#設定)
20
+ - [使用方式](#使用方式)
21
+ - [資料對應](#資料對應)
22
+ - [專案結構](#專案結構)
23
+ - [設定優先級](#設定優先級)
24
+ - [常見問題](#常見問題)
25
+ - [開發](#開發)
26
+ - [授權](#授權)
27
+ - [致謝](#致謝)
28
+
29
+ ---
30
+
31
+ ## 專案截圖
32
+
33
+ <!--
34
+ 將截圖放進 docs/screenshots/ 目錄,檔名對應下方即可自動顯示。
35
+ 建議尺寸:1600x900 或以上,PNG 格式。
36
+ -->
37
+
38
+ ### Dashboard
39
+
40
+ ![Dashboard](docs/screenshots/dashboard.png)
41
+
42
+ ### PVE Cluster 詳情
43
+
44
+ ![PVE Cluster](docs/screenshots/pve-cluster.png)
45
+
46
+ ### Sync Jobs 同步歷史
47
+
48
+ ![Sync Jobs](docs/screenshots/sync-jobs.png)
49
+
50
+ ### Telegram 通知
51
+
52
+ ![Telegram Notification](docs/screenshots/telegram-notification.png)
53
+
54
+ ---
55
+
13
56
  ## 功能特色
14
57
 
15
58
  ### Proxmox VE 同步
@@ -18,6 +61,8 @@
18
61
  - VM / CT → NetBox **Virtual Machine**(狀態、規格、磁碟、標籤)
19
62
  - QEMU Guest Agent 整合,從執行中 VM 取得實際 IP 位址
20
63
  - 配置漂移偵測:自動發現 VM 的 CPU / 記憶體設定變更
64
+ - 節點放置檢查:VM 描述 (Notes) 內若標記 `node: <預期節點>`,當實際所在節點與標記不符時透過 Telegram 提醒
65
+ - Guest Agent 異常提醒:VM 執行中卻取不到 IP(QEMU Guest Agent 未啟用或未安裝)時透過 Telegram 提醒
21
66
  - 增量同步:透過 SQLite state_db 追蹤變更,避免全量更新
22
67
 
23
68
  ### 多叢集支援
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxsync
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -36,7 +36,7 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
36
36
 
37
37
  # proxsync
38
38
 
39
- [![Version](https://img.shields.io/badge/version-2.1.0-blue.svg)](https://github.com/XiuJie2/proxsync)
39
+ [![Version](https://img.shields.io/badge/version-2.1.1-blue.svg)](https://github.com/XiuJie2/proxsync)
40
40
  [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/)
41
41
  [![NetBox 4.5–4.6](https://img.shields.io/badge/NetBox-4.5%20–%204.6-green.svg)](https://netbox.dev/)
42
42
  [![Proxmox VE](https://img.shields.io/badge/Proxmox-VE%20%2B%20PBS-orange.svg)](https://www.proxmox.com/)
@@ -46,6 +46,49 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
46
46
 
47
47
  ---
48
48
 
49
+ ## 目錄
50
+
51
+ - [專案截圖](#專案截圖)
52
+ - [功能特色](#功能特色)
53
+ - [系統需求](#系統需求)
54
+ - [安裝](#安裝)
55
+ - [設定](#設定)
56
+ - [使用方式](#使用方式)
57
+ - [資料對應](#資料對應)
58
+ - [專案結構](#專案結構)
59
+ - [設定優先級](#設定優先級)
60
+ - [常見問題](#常見問題)
61
+ - [開發](#開發)
62
+ - [授權](#授權)
63
+ - [致謝](#致謝)
64
+
65
+ ---
66
+
67
+ ## 專案截圖
68
+
69
+ <!--
70
+ 將截圖放進 docs/screenshots/ 目錄,檔名對應下方即可自動顯示。
71
+ 建議尺寸:1600x900 或以上,PNG 格式。
72
+ -->
73
+
74
+ ### Dashboard
75
+
76
+ ![Dashboard](docs/screenshots/dashboard.png)
77
+
78
+ ### PVE Cluster 詳情
79
+
80
+ ![PVE Cluster](docs/screenshots/pve-cluster.png)
81
+
82
+ ### Sync Jobs 同步歷史
83
+
84
+ ![Sync Jobs](docs/screenshots/sync-jobs.png)
85
+
86
+ ### Telegram 通知
87
+
88
+ ![Telegram Notification](docs/screenshots/telegram-notification.png)
89
+
90
+ ---
91
+
49
92
  ## 功能特色
50
93
 
51
94
  ### Proxmox VE 同步
@@ -54,6 +97,8 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
54
97
  - VM / CT → NetBox **Virtual Machine**(狀態、規格、磁碟、標籤)
55
98
  - QEMU Guest Agent 整合,從執行中 VM 取得實際 IP 位址
56
99
  - 配置漂移偵測:自動發現 VM 的 CPU / 記憶體設定變更
100
+ - 節點放置檢查:VM 描述 (Notes) 內若標記 `node: <預期節點>`,當實際所在節點與標記不符時透過 Telegram 提醒
101
+ - Guest Agent 異常提醒:VM 執行中卻取不到 IP(QEMU Guest Agent 未啟用或未安裝)時透過 Telegram 提醒
57
102
  - 增量同步:透過 SQLite state_db 追蹤變更,避免全量更新
58
103
 
59
104
  ### 多叢集支援
@@ -44,6 +44,8 @@ class OptimizedPVEToNetBoxSync:
44
44
  'config_drifts_detected': 0,
45
45
  'tag_changes': 0,
46
46
  'resources_alert': 0,
47
+ 'node_mismatch_alerts': 0,
48
+ 'no_ip_alerts': 0,
47
49
  'total_vms': 0,
48
50
  'success_vms': 0,
49
51
  'elapsed_time': 0
@@ -125,12 +127,25 @@ class OptimizedPVEToNetBoxSync:
125
127
  self.error_log = []
126
128
 
127
129
  # ---------- Telegram ----------
128
- def send_telegram_notification(self, message: str):
130
+ # Max Telegram messages per sync run; excess are counted and reported in the summary.
131
+ _MAX_TELEGRAM_PER_SYNC = 20
132
+
133
+ def send_telegram_notification(self, message: str) -> bool:
134
+ if not getattr(self, 'notify_on_sync', True):
135
+ return False
136
+ if not self.telegram_bot_token or not self.telegram_chat_id:
137
+ return False
138
+ # Throttle: cap messages per sync run to avoid flooding on bulk changes.
139
+ sent = getattr(self, '_telegram_sent', 0)
140
+ if sent >= self._MAX_TELEGRAM_PER_SYNC:
141
+ self._telegram_suppressed = getattr(self, '_telegram_suppressed', 0) + 1
142
+ return False
129
143
  try:
130
144
  url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
131
145
  payload = {'chat_id': self.telegram_chat_id, 'text': message, 'parse_mode': 'HTML'}
132
146
  response = requests.post(url, data=payload, timeout=10)
133
147
  if response.status_code == 200:
148
+ self._telegram_sent = sent + 1
134
149
  print(f"✓ Telegram 通知已發送")
135
150
  return True
136
151
  else:
@@ -233,6 +248,10 @@ class OptimizedPVEToNetBoxSync:
233
248
  extras.append(f"🏷️ 標籤變更: {self.stats['tag_changes']}")
234
249
  if self.stats.get('resources_alert', 0) > 0:
235
250
  extras.append(f"⚠️ 資源告警: {self.stats['resources_alert']}")
251
+ if self.stats.get('node_mismatch_alerts', 0) > 0:
252
+ extras.append(f"📍 節點放置異常: {self.stats['node_mismatch_alerts']}")
253
+ if self.stats.get('no_ip_alerts', 0) > 0:
254
+ extras.append(f"🌐 運行中但無 IP: {self.stats['no_ip_alerts']}")
236
255
  if extras:
237
256
  message += "\n🔍 檢測發現:\n" + "\n".join(extras)
238
257
  if self.error_log:
@@ -241,6 +260,9 @@ class OptimizedPVEToNetBoxSync:
241
260
  message += f"• {error['vm_name']} - {error['ip_address']}\n"
242
261
  if len(self.error_log) > 5:
243
262
  message += f"• ... 還有 {len(self.error_log) - 5} 個錯誤\n"
263
+ suppressed = getattr(self, '_telegram_suppressed', 0)
264
+ if suppressed:
265
+ message += f"\n📵 本輪通知已達上限,{suppressed} 則訊息被壓制(詳見 NetBox 漂移事件記錄)"
244
266
  self.send_telegram_notification(message)
245
267
 
246
268
  # ---------- API 連接 ----------
@@ -327,6 +349,7 @@ class OptimizedPVEToNetBoxSync:
327
349
  self.nb_cache['platforms'][platform.name.lower()] = platform
328
350
  for role in self.nb_api.dcim.device_roles.all():
329
351
  self.nb_cache['roles'][role.name.lower()] = role
352
+ self.nb_cache['device_roles'][role.name.lower()] = role
330
353
  for cluster in self.nb_api.virtualization.clusters.all():
331
354
  self.nb_cache['clusters'][str(cluster.id)] = cluster
332
355
  for site in self.nb_api.dcim.sites.all():
@@ -335,8 +358,6 @@ class OptimizedPVEToNetBoxSync:
335
358
  self.nb_cache['manufacturers'][manufacturer.name.lower()] = manufacturer
336
359
  for device_type in self.nb_api.dcim.device_types.all():
337
360
  self.nb_cache['device_types'][device_type.model.lower()] = device_type
338
- for device_role in self.nb_api.dcim.device_roles.all():
339
- self.nb_cache['device_roles'][device_role.name.lower()] = device_role
340
361
  for cluster_type in self.nb_api.virtualization.cluster_types.all():
341
362
  self.nb_cache['cluster_types'][cluster_type.name.lower()] = cluster_type
342
363
  elapsed = time.time() - start_time
@@ -393,13 +414,13 @@ class OptimizedPVEToNetBoxSync:
393
414
  vm['node'] = node_name
394
415
  all_vms.append(vm)
395
416
  self.pve_cache['vms_by_node'][node_name] = all_vms
396
- for vm in all_vms[:10]:
417
+ for vm in all_vms:
397
418
  try:
398
419
  if vm['type'] == 'qemu':
399
420
  vm['config'] = self.pve_api.nodes(node_name).qemu(vm['vmid']).config.get()
400
421
  else:
401
422
  vm['config'] = self.pve_api.nodes(node_name).lxc(vm['vmid']).config.get()
402
- except:
423
+ except Exception:
403
424
  pass
404
425
  except Exception as e:
405
426
  print(f" 加載節點 {node_name} 的虛擬機失敗: {e}")
@@ -1147,6 +1168,25 @@ class OptimizedPVEToNetBoxSync:
1147
1168
  disks[key] = m.group(1) if m else val_str[:80]
1148
1169
  return disks
1149
1170
 
1171
+ @staticmethod
1172
+ def _norm_desc(s) -> str:
1173
+ """Normalize description: unify line endings, strip whitespace."""
1174
+ return (s or '').replace('\r\n', '\n').replace('\r', '\n').strip()
1175
+
1176
+ @staticmethod
1177
+ def _parse_expected_node(description: str) -> Optional[str]:
1178
+ """從 VM 描述 (Notes) 解析使用者標記的預期節點。
1179
+
1180
+ 支援格式如 "node: 181" 或 "node:181"(半形/全形冒號皆可,
1181
+ 且不區分大小寫,Node/NODE/node 皆可),必須獨佔一行。
1182
+ 找不到則回傳 None。
1183
+ """
1184
+ import re
1185
+ if not description:
1186
+ return None
1187
+ m = re.search(r'(?im)^\s*node\s*[::]\s*(\S+)\s*$', description)
1188
+ return m.group(1).strip() if m else None
1189
+
1150
1190
  def _write_drift_event(self, vm_name: str, vmid: int, drift_type: str,
1151
1191
  field_name: str, old_value: str, new_value: str,
1152
1192
  notified: bool = False):
@@ -1169,13 +1209,13 @@ class OptimizedPVEToNetBoxSync:
1169
1209
  except Exception as e:
1170
1210
  print(f" ⚠ 無法寫入漂移事件記錄: {e}")
1171
1211
 
1172
- def detect_new_vm(self, vm_id: int, vm_name: str, node_name: str):
1212
+ def detect_new_vm(self, vm_id: int, vm_name: str, node_name: str, last_config=None):
1173
1213
  """若此 vmid 在先前同步中不存在(但叢集有先前記錄),視為新增 VM。"""
1174
1214
  if not self.enhanced_mode or not self.state_db:
1175
1215
  return
1176
1216
  if not self._known_vmids:
1177
1217
  return # 首次同步,無先前記錄,不觸發
1178
- if self.state_db.get_last_vm_config(vm_id, self.cluster_name):
1218
+ if last_config:
1179
1219
  return # 已有記錄,不是新增
1180
1220
  print(f"🆕 VM {vm_name} 新增偵測 (ID: {vm_id}, 節點: {node_name})")
1181
1221
  self.stats['config_drifts_detected'] += 1
@@ -1314,15 +1354,29 @@ class OptimizedPVEToNetBoxSync:
1314
1354
  print(f" 🧹 Log 清理:刪除 {d_del} 筆漂移事件、{t_del} 筆操作記錄(>{days} 天)")
1315
1355
  except Exception as exc:
1316
1356
  print(f" ⚠️ Log 清理失敗: {exc}")
1357
+ # Also clean up state_db vm_config_history (keep latest row per VM, remove old history)
1358
+ if self.state_db:
1359
+ try:
1360
+ from pve_sync_plugin.models import PvePluginSettings
1361
+ days = PvePluginSettings.load().log_retention_days
1362
+ if days:
1363
+ h_del = self.state_db.cleanup_old_history(self.cluster_name, days)
1364
+ if h_del:
1365
+ print(f" 🧹 state_db 清理:刪除 {h_del} 筆舊配置歷史(>{days} 天)")
1366
+ except Exception as exc:
1367
+ print(f" ⚠️ state_db 歷史清理失敗: {exc}")
1317
1368
 
1318
1369
  def sync_vm_tasks(self):
1319
1370
  """從 PVE 各節點抓取 VM 操作記錄(建立/刪除/Clone/遷移)並同步到 NetBox。"""
1371
+ import calendar
1372
+ import datetime as _dt
1320
1373
  try:
1321
1374
  from django.utils import timezone
1322
1375
  from pve_sync_plugin.models import PveVmTaskLog
1323
1376
  except ImportError:
1324
1377
  return
1325
1378
 
1379
+ _utc = _dt.timezone.utc
1326
1380
  VM_TASK_TYPES = {
1327
1381
  "qmcreate", "qmdestroy", "qmclone", "qmmigrate", "qmrestore",
1328
1382
  "qmstart", "qmstop", "qmshutdown", "qmreboot",
@@ -1356,10 +1410,18 @@ class OptimizedPVEToNetBoxSync:
1356
1410
  cluster_name=self.cluster_name
1357
1411
  ).order_by("-start_time").values_list("start_time", flat=True).first()
1358
1412
  if latest:
1359
- import calendar
1360
1413
  since_ts = int(calendar.timegm(latest.utctimetuple())) + 1
1361
1414
  else:
1362
1415
  since_ts = int(time.time()) - 30 * 86400
1416
+
1417
+ # Pre-fetch existing UPIDs in the window → O(1) lookup instead of N+1 queries
1418
+ since_dt = _dt.datetime.fromtimestamp(since_ts, tz=_utc)
1419
+ existing_upids: set = set(
1420
+ PveVmTaskLog.objects
1421
+ .filter(cluster_name=self.cluster_name, start_time__gte=since_dt)
1422
+ .values_list("upid", flat=True)
1423
+ )
1424
+ new_upids: set = set() # UPIDs inserted this run (avoid duplicates within same run)
1363
1425
  new_count = 0
1364
1426
 
1365
1427
  for node_name in self.pve_cache.get("vms_by_node", {}):
@@ -1383,8 +1445,8 @@ class OptimizedPVEToNetBoxSync:
1383
1445
  except (ValueError, TypeError):
1384
1446
  continue
1385
1447
 
1386
- # UPID 去重
1387
- if PveVmTaskLog.objects.filter(upid=upid).exists():
1448
+ # UPID 去重(使用預先撈取的集合,避免 N+1 查詢)
1449
+ if upid in existing_upids or upid in new_upids:
1388
1450
  continue
1389
1451
 
1390
1452
  task_type = task.get("type")
@@ -1394,8 +1456,6 @@ class OptimizedPVEToNetBoxSync:
1394
1456
  status = task.get("status", "") or ""
1395
1457
  vm_name = vmid_to_name.get(vmid, f"VM-{vmid}")
1396
1458
 
1397
- import datetime as _dt
1398
- _utc = _dt.timezone.utc
1399
1459
  start_dt = _dt.datetime.fromtimestamp(start_ts, tz=_utc) if start_ts else timezone.now()
1400
1460
  end_dt = _dt.datetime.fromtimestamp(end_ts, tz=_utc) if end_ts else None
1401
1461
 
@@ -1412,6 +1472,7 @@ class OptimizedPVEToNetBoxSync:
1412
1472
  status=status,
1413
1473
  notified_telegram=False,
1414
1474
  )
1475
+ new_upids.add(upid)
1415
1476
  new_count += 1
1416
1477
 
1417
1478
  # Telegram 通知(僅近期且已完成的操作)
@@ -1439,11 +1500,12 @@ class OptimizedPVEToNetBoxSync:
1439
1500
 
1440
1501
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1441
1502
  current_tags: List[str], network_interfaces: List[Dict] = None,
1442
- current_node: str = None) -> List[Dict]:
1503
+ current_node: str = None, last_config=None) -> List[Dict]:
1443
1504
  if not self.enhanced_mode or not self.state_db:
1444
1505
  return []
1445
1506
  drifts = []
1446
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1507
+ if last_config is None:
1508
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1447
1509
  if not last_config:
1448
1510
  return drifts
1449
1511
  current_memory = int(vm_config.get('memory', 0))
@@ -1524,8 +1586,8 @@ class OptimizedPVEToNetBoxSync:
1524
1586
  self._write_drift_event(vm_name, vm_id, 'disk_change', k, old_v, new_v, notified=True)
1525
1587
 
1526
1588
  # VM 描述變更偵測
1527
- current_desc = (vm_config.get('description') or '').strip()[:500]
1528
- old_desc = (last_config.get('description') or '').strip()
1589
+ current_desc = self._norm_desc(vm_config.get('description'))[:500].strip()
1590
+ old_desc = self._norm_desc(last_config.get('description'))[:500].strip()
1529
1591
  if old_desc and old_desc != current_desc:
1530
1592
  self.stats['config_drifts_detected'] += 1
1531
1593
  print(f"📝 VM {vm_name} 描述變更")
@@ -1565,7 +1627,7 @@ class OptimizedPVEToNetBoxSync:
1565
1627
  return drifts
1566
1628
 
1567
1629
  def should_sync_vm(self, vm_id: int, vm_config: Dict[str, Any], tags: List[str],
1568
- network_interfaces: List[Dict] = None) -> bool:
1630
+ network_interfaces: List[Dict] = None, last_config=None) -> bool:
1569
1631
  if not self.sync_config.get('incremental', True):
1570
1632
  return True
1571
1633
  if self.sync_config.get('force_full_sync', False):
@@ -1573,34 +1635,35 @@ class OptimizedPVEToNetBoxSync:
1573
1635
  current_hash = compute_config_hash(vm_config, tags, network_interfaces)
1574
1636
  if not self.state_db:
1575
1637
  return True
1576
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1638
+ if last_config is None:
1639
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1577
1640
  if not last_config:
1578
1641
  return True
1579
1642
  if current_hash != last_config.get('config_hash'):
1580
1643
  return True
1581
1644
  return False
1582
1645
 
1583
- def detect_tag_changes(self, vm_id: int, vm_name: str, current_tags: List[str]) -> List[Dict]:
1646
+ def detect_tag_changes(self, vm_id: int, vm_name: str, current_tags: List[str],
1647
+ last_config=None) -> List[Dict]:
1584
1648
  if not self.enhanced_mode or not self.state_db:
1585
1649
  return []
1586
- changes = []
1587
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1650
+ if last_config is None:
1651
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1588
1652
  if not last_config:
1589
- return changes
1653
+ return []
1590
1654
  old_tags = set(last_config.get('tags', []))
1591
1655
  new_tags = set(current_tags)
1592
1656
  added = new_tags - old_tags
1593
1657
  removed = old_tags - new_tags
1658
+ changes = []
1594
1659
  for tag in added:
1595
1660
  changes.append({'type': 'added', 'tag': tag})
1596
1661
  self.stats['tag_changes'] += 1
1597
1662
  print(f"🏷️ VM {vm_name} 新增標籤: {tag}")
1598
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', '', tag, notified=False)
1599
1663
  for tag in removed:
1600
1664
  changes.append({'type': 'removed', 'tag': tag})
1601
1665
  self.stats['tag_changes'] += 1
1602
1666
  print(f"🏷️ VM {vm_name} 移除標籤: {tag}")
1603
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', tag, '', notified=False)
1604
1667
  if changes:
1605
1668
  added_list = [c['tag'] for c in changes if c['type'] == 'added']
1606
1669
  removed_list = [c['tag'] for c in changes if c['type'] == 'removed']
@@ -1616,7 +1679,12 @@ class OptimizedPVEToNetBoxSync:
1616
1679
  f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1617
1680
  f"{detail}\n請確認是否為預期變更。"
1618
1681
  )
1619
- self.send_telegram_notification(tag_msg)
1682
+ notified = self.send_telegram_notification(tag_msg)
1683
+ for c in changes:
1684
+ if c['type'] == 'added':
1685
+ self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', '', c['tag'], notified=notified)
1686
+ else:
1687
+ self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', c['tag'], '', notified=notified)
1620
1688
  return changes
1621
1689
 
1622
1690
  def detect_ip_change(self, vm_id: int, vm_name: str, current_ip: str):
@@ -1643,6 +1711,36 @@ class OptimizedPVEToNetBoxSync:
1643
1711
  self.send_telegram_notification(ip_msg)
1644
1712
  self._write_drift_event(vm_name, vm_id, 'ip_change', 'primary_ip', old_ip, current_ip, notified=True)
1645
1713
 
1714
+ def check_agent_ip_missing(self, vm_id: str, vm_name: str, vm_type: str, is_template: bool,
1715
+ vm_status: str, qemu_agent_enabled: bool, ip_str: str):
1716
+ """VM 已在執行中,卻完全取不到 IP —— 多半是 QEMU Guest Agent 未啟用/未安裝。
1717
+
1718
+ 每次同步皆檢查(非一次性事件),只要問題未解決就會持續提醒。
1719
+ """
1720
+ if vm_type != 'qemu' or is_template or vm_status != 'running' or ip_str:
1721
+ return
1722
+ print(f"🌐 VM {vm_name} 運行中但無法取得 IP")
1723
+ self.stats['no_ip_alerts'] = self.stats.get('no_ip_alerts', 0) + 1
1724
+ if qemu_agent_enabled:
1725
+ detail = (
1726
+ "⚠️ QEMU Guest Agent 已在設定中啟用,但仍無法取得 IP,"
1727
+ "請確認 VM 內部的 qemu-guest-agent 服務是否已安裝並執行中。"
1728
+ )
1729
+ else:
1730
+ detail = (
1731
+ "⚠️ 此 VM 尚未啟用 QEMU Guest Agent(Options → QEMU Guest Agent),"
1732
+ "請啟用後於 VM 內安裝對應套件(qemu-guest-agent)。"
1733
+ )
1734
+ msg = (
1735
+ f"🌐 <b>VM 運行中但無法取得 IP</b>\n\n"
1736
+ f"🖥️ 名稱: <b>{vm_name}</b> (ID: {vm_id})\n"
1737
+ f"🔀 叢集: {self.cluster_name}\n"
1738
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1739
+ f"{detail}"
1740
+ )
1741
+ self.send_telegram_notification(msg)
1742
+ self._write_drift_event(vm_name, int(vm_id), 'agent_ip_missing', 'primary_ip', '', '', notified=True)
1743
+
1646
1744
  # ---------- 虛擬機處理主邏輯 ----------
1647
1745
  def process_virtual_machine(self, vm_data: Dict, device, cluster: Dict, force: bool = False) -> bool:
1648
1746
  vm_id = str(vm_data['vmid'])
@@ -1660,7 +1758,9 @@ class OptimizedPVEToNetBoxSync:
1660
1758
  else:
1661
1759
  vm_config = self.pve_api.nodes(node_name).lxc(vm_data['vmid']).config.get()
1662
1760
  except Exception as e:
1663
- print(f" 獲取 VM 配置失敗: {e}")
1761
+ error_msg = str(e)
1762
+ print(f" 獲取 VM 配置失敗: {error_msg}")
1763
+ self.log_vm_sync_error(original_vm_name, int(vm_id), f"無法取得 PVE 配置: {error_msg}")
1664
1764
  return False
1665
1765
  is_template = vm_data.get('is_template', False) or vm_config.get('template', 0) == 1
1666
1766
  boot_choice = 'on' if vm_config.get('onboot', 0) == 1 else 'off'
@@ -1684,10 +1784,16 @@ class OptimizedPVEToNetBoxSync:
1684
1784
  tag_ids.append(tag.id)
1685
1785
  except Exception as e:
1686
1786
  print(f" 建立標籤失敗 {tag_name}: {e}")
1787
+ # Read last_config once; share with detect_new_vm / detect_tag_changes /
1788
+ # detect_config_drift / should_sync_vm to avoid repeated SQLite round-trips.
1789
+ last_config = (
1790
+ self.state_db.get_last_vm_config(int(vm_id), self.cluster_name)
1791
+ if (self.enhanced_mode and self.state_db) else None
1792
+ )
1687
1793
  if self.enhanced_mode:
1688
- self.detect_new_vm(int(vm_id), original_vm_name, node_name)
1794
+ self.detect_new_vm(int(vm_id), original_vm_name, node_name, last_config=last_config)
1689
1795
  if self.enhanced_mode and tag_names:
1690
- self.detect_tag_changes(int(vm_id), original_vm_name, tag_names)
1796
+ self.detect_tag_changes(int(vm_id), original_vm_name, tag_names, last_config=last_config)
1691
1797
  vm_status = vm_data.get('status', '')
1692
1798
  if tag_names and vm_status in ('running', 'stopped'):
1693
1799
  first_tag = tag_names[0].lower()
@@ -1716,6 +1822,26 @@ class OptimizedPVEToNetBoxSync:
1716
1822
  ⚠️ 虛擬機應處於關機狀態,請確認開機原因。
1717
1823
  """
1718
1824
  self.send_telegram_notification(message)
1825
+
1826
+ # 節點放置檢查:VM 描述 (Notes) 內若標記 "node: <預期節點>",
1827
+ # 而目前實際所在節點與其不符,則提醒(每次同步皆檢查,非一次性事件)。
1828
+ expected_node = self._parse_expected_node(vm_config.get('description', ''))
1829
+ if expected_node and expected_node != node_name:
1830
+ print(f"📍 VM {original_vm_name} 節點不符期望: 目前={node_name}, 期望={expected_node}")
1831
+ self.stats['node_mismatch_alerts'] += 1
1832
+ node_mismatch_msg = (
1833
+ f"📍 <b>VM 節點與 Notes 標記不符</b>\n\n"
1834
+ f"🖥️ 名稱: <b>{original_vm_name}</b> (ID: {vm_id})\n"
1835
+ f"🔀 叢集: {self.cluster_name}\n"
1836
+ f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1837
+ f"📌 目前節點: <code>{node_name}</code>\n"
1838
+ f"📋 Notes 標記節點: <code>{expected_node}</code>\n\n"
1839
+ f"⚠️ 請確認此 VM 是否應遷回指定節點。"
1840
+ )
1841
+ self.send_telegram_notification(node_mismatch_msg)
1842
+ self._write_drift_event(original_vm_name, int(vm_id), 'node_mismatch', 'node',
1843
+ node_name, expected_node, notified=True)
1844
+
1719
1845
  role_id = None
1720
1846
  vm_pool = self.get_vm_pool(vm_data['vmid'], vm_type)
1721
1847
  if vm_pool:
@@ -1770,8 +1896,9 @@ class OptimizedPVEToNetBoxSync:
1770
1896
  })
1771
1897
  if self.enhanced_mode:
1772
1898
  self.detect_config_drift(int(vm_id), original_vm_name, vm_config, tag_names,
1773
- network_interfaces, current_node=node_name)
1774
- if not force and not self.should_sync_vm(int(vm_id), vm_config, tag_names, network_interfaces):
1899
+ network_interfaces, current_node=node_name, last_config=last_config)
1900
+ if not force and not self.should_sync_vm(int(vm_id), vm_config, tag_names, network_interfaces,
1901
+ last_config=last_config):
1775
1902
  cached_vm = self.nb_cache['virtual_machines_by_serial'].get(f"{vm_id}::{cluster['id']}")
1776
1903
  if cached_vm is None:
1777
1904
  # VM 在 NetBox 中不存在(例如隨節點被連帶刪除),
@@ -1817,18 +1944,20 @@ class OptimizedPVEToNetBoxSync:
1817
1944
  except Exception as e:
1818
1945
  print(f" 設定 VM 主 IP 失敗: {e}")
1819
1946
  print(f" ℹ️ VM {original_vm_name} 無配置變更,跳過同步")
1947
+ cur_ip = getattr(skip_primary_ip, 'address', None) \
1948
+ or getattr(getattr(cached_vm, 'primary_ip4', None), 'address', None) or ''
1949
+ self.check_agent_ip_missing(vm_id, original_vm_name, vm_type, is_template,
1950
+ vm_status, qemu_agent_enabled, cur_ip)
1820
1951
  if self.state_db:
1821
1952
  try:
1822
1953
  config_hash = compute_config_hash(vm_config, tag_names, network_interfaces)
1823
1954
  memory = int(vm_config.get('memory', 0))
1824
1955
  vcpus_save = int(vm_config.get('vcpus', vcpus))
1825
- cur_ip = getattr(skip_primary_ip, 'address', None) \
1826
- or getattr(getattr(cached_vm, 'primary_ip4', None), 'address', None) or ''
1827
1956
  self.state_db.save_vm_config_snapshot(
1828
1957
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1829
1958
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1830
1959
  node=node_name, primary_ip=cur_ip, vm_name=original_vm_name,
1831
- description=(vm_config.get('description') or '').strip()[:500],
1960
+ description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1832
1961
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1833
1962
  )
1834
1963
  except Exception as _snap_err:
@@ -1885,7 +2014,7 @@ class OptimizedPVEToNetBoxSync:
1885
2014
  update_data = {
1886
2015
  'name': vm_name, 'cluster': cluster['id'], 'device': device.id, 'role': role_id,
1887
2016
  'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)), 'status': status,
1888
- 'description': (vm_config.get('description') or '')[:200], 'platform': platform_id,
2017
+ 'description': self._norm_desc(vm_config.get('description'))[:200], 'platform': platform_id,
1889
2018
  'start_on_boot': boot_choice
1890
2019
  }
1891
2020
  if tag_ids:
@@ -1904,7 +2033,7 @@ class OptimizedPVEToNetBoxSync:
1904
2033
  vm_data_dict = {
1905
2034
  'serial': vm_id, 'name': vm_name, 'cluster': cluster['id'], 'device': device.id,
1906
2035
  'role': role_id, 'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)),
1907
- 'status': status, 'description': (vm_config.get('description') or '')[:200],
2036
+ 'status': status, 'description': self._norm_desc(vm_config.get('description'))[:200],
1908
2037
  'platform': platform_id, 'start_on_boot': boot_choice
1909
2038
  }
1910
2039
  if tag_ids:
@@ -1932,6 +2061,8 @@ class OptimizedPVEToNetBoxSync:
1932
2061
  print(f" 設定 VM 主 IP 失敗: {e}")
1933
2062
  if self.enhanced_mode and primary_ip_str:
1934
2063
  self.detect_ip_change(int(vm_id), original_vm_name, primary_ip_str)
2064
+ self.check_agent_ip_missing(vm_id, original_vm_name, vm_type, is_template,
2065
+ vm_status, qemu_agent_enabled, primary_ip_str)
1935
2066
  print(f" 標籤: {len(tag_ids)}個, 介面: {interface_count}個, 磁碟: {disk_count}個, 大小: {disk_size}MB")
1936
2067
  if self.enhanced_mode and self.state_db:
1937
2068
  config_hash = compute_config_hash(vm_config, tag_names, network_interfaces)
@@ -1941,7 +2072,7 @@ class OptimizedPVEToNetBoxSync:
1941
2072
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1942
2073
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1943
2074
  node=node_name, primary_ip=primary_ip_str, vm_name=original_vm_name,
1944
- description=(vm_config.get('description') or '').strip()[:500],
2075
+ description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1945
2076
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1946
2077
  )
1947
2078
  return True
@@ -1967,7 +2098,7 @@ class OptimizedPVEToNetBoxSync:
1967
2098
  node=node_name,
1968
2099
  primary_ip=_ip,
1969
2100
  vm_name=original_vm_name,
1970
- description=(vm_config.get('description') or '').strip()[:500],
2101
+ description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1971
2102
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1972
2103
  )
1973
2104
  except Exception:
@@ -2010,6 +2141,18 @@ class OptimizedPVEToNetBoxSync:
2010
2141
  print("=" * 50)
2011
2142
  if self.enhanced_mode and self.state_db:
2012
2143
  self.sync_log_id = self.state_db.start_sync_log(self.cluster_name)
2144
+ # Load notify_on_sync from PveClusterConfig (DB setting takes precedence over default True)
2145
+ self.notify_on_sync = True
2146
+ try:
2147
+ from pve_sync_plugin.models import PveClusterConfig
2148
+ cfg = PveClusterConfig.objects.filter(name=self.cluster_name).first()
2149
+ if cfg is not None:
2150
+ self.notify_on_sync = cfg.notify_on_sync
2151
+ except Exception:
2152
+ pass
2153
+ # Reset per-sync Telegram counters
2154
+ self._telegram_sent = 0
2155
+ self._telegram_suppressed = 0
2013
2156
  sync_mode = "增量" if self.sync_config.get('incremental', True) else "全量"
2014
2157
  start_message = f"""
2015
2158
  🔄 <b>PVE-NetBox 同步開始</b>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "proxsync"
7
- version = "2.1.0"
7
+ version = "2.1.2"
8
8
  description = "NetBox plugin for Proxmox VE inventory synchronization"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -154,7 +154,7 @@ class StateDB:
154
154
  with sqlite3.connect(self.db_path) as conn:
155
155
  conn.execute(
156
156
  """
157
- INSERT INTO vm_config_history
157
+ INSERT OR REPLACE INTO vm_config_history
158
158
  (vm_id, cluster_name, config_hash, memory, vcpus, tags_json,
159
159
  node, primary_ip, vm_name, description, disk_summary)
160
160
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -256,6 +256,28 @@ class StateDB:
256
256
  })
257
257
  return changes
258
258
 
259
+ def cleanup_old_history(self, cluster_name: str, days: int) -> int:
260
+ """Delete vm_config_history rows older than `days`, keeping the latest row per VM."""
261
+ import datetime as _dt
262
+ cutoff = (_dt.datetime.utcnow() - _dt.timedelta(days=days)).strftime('%Y-%m-%d %H:%M:%S')
263
+ with self._lock:
264
+ with sqlite3.connect(self.db_path) as conn:
265
+ result = conn.execute(
266
+ """
267
+ DELETE FROM vm_config_history
268
+ WHERE cluster_name = ?
269
+ AND sync_time < ?
270
+ AND sync_time NOT IN (
271
+ SELECT MAX(sync_time) FROM vm_config_history
272
+ WHERE cluster_name = ?
273
+ GROUP BY vm_id
274
+ )
275
+ """,
276
+ (cluster_name, cutoff, cluster_name),
277
+ )
278
+ conn.commit()
279
+ return result.rowcount
280
+
259
281
  # ========== 节点状态历史 ==========
260
282
 
261
283
  def save_node_status(self, node_name: str, cluster_name: str, status: str):
@@ -507,7 +529,7 @@ def compute_config_hash(vm_config: Dict[str, Any], tags: List[str], network_inte
507
529
  'ostype': vm_config.get('ostype', ''),
508
530
  'cores': vm_config.get('cores', 1),
509
531
  'sockets': vm_config.get('sockets', 1),
510
- 'description': vm_config.get('description', '')[:200], # 限制长度
532
+ 'description': (vm_config.get('description') or '').replace('\r\n', '\n').replace('\r', '\n').strip()[:200].strip(),
511
533
  'tags': sorted(tags), # 排序以确保一致性
512
534
  'networks': net_config # 添加网络接口配置
513
535
  }
File without changes
File without changes
File without changes
File without changes