proxsync 2.0.7__tar.gz → 2.0.8__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.7/proxsync.egg-info → proxsync-2.0.8}/PKG-INFO +4 -4
  2. {proxsync-2.0.7 → proxsync-2.0.8/proxsync.egg-info}/PKG-INFO +4 -4
  3. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync.py +94 -129
  4. {proxsync-2.0.7 → proxsync-2.0.8}/pyproject.toml +4 -4
  5. {proxsync-2.0.7 → proxsync-2.0.8}/state_db.py +2 -24
  6. {proxsync-2.0.7 → proxsync-2.0.8}/MANIFEST.in +0 -0
  7. {proxsync-2.0.7 → proxsync-2.0.8}/README.md +0 -0
  8. {proxsync-2.0.7 → proxsync-2.0.8}/config.py +0 -0
  9. {proxsync-2.0.7 → proxsync-2.0.8}/pbs_sync.py +0 -0
  10. {proxsync-2.0.7 → proxsync-2.0.8}/proxsync.egg-info/SOURCES.txt +0 -0
  11. {proxsync-2.0.7 → proxsync-2.0.8}/proxsync.egg-info/dependency_links.txt +0 -0
  12. {proxsync-2.0.7 → proxsync-2.0.8}/proxsync.egg-info/requires.txt +0 -0
  13. {proxsync-2.0.7 → proxsync-2.0.8}/proxsync.egg-info/top_level.txt +0 -0
  14. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/__init__.py +0 -0
  15. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/api/__init__.py +0 -0
  16. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/api/serializers.py +0 -0
  17. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/api/urls.py +0 -0
  18. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/api/views.py +0 -0
  19. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/apps.py +0 -0
  20. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/choices.py +0 -0
  21. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/filtersets.py +0 -0
  22. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/forms.py +0 -0
  23. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/management/__init__.py +0 -0
  24. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/management/commands/__init__.py +0 -0
  25. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  26. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  27. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  28. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  29. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  30. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  31. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  32. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  33. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  34. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  35. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  36. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  37. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  38. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  39. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  40. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +0 -0
  41. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0015_pvevmtasklog.py +0 -0
  42. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py +0 -0
  43. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/migrations/__init__.py +0 -0
  44. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/models.py +0 -0
  45. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/navigation.py +0 -0
  46. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/signals.py +0 -0
  47. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/sync/__init__.py +0 -0
  48. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/sync/config_bridge.py +0 -0
  49. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/sync/engine.py +0 -0
  50. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/tables.py +0 -0
  51. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/tasks.py +0 -0
  52. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/template_content.py +0 -0
  53. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  54. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  55. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html +0 -0
  56. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  57. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  58. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  59. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  60. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +0 -0
  61. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html +0 -0
  62. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  63. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  64. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +0 -0
  65. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +0 -0
  66. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templatetags/__init__.py +0 -0
  67. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/templatetags/pve_sync_tags.py +0 -0
  68. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/urls.py +0 -0
  69. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/utils.py +0 -0
  70. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/view_helpers.py +0 -0
  71. {proxsync-2.0.7 → proxsync-2.0.8}/pve_sync_plugin/views.py +0 -0
  72. {proxsync-2.0.7 → proxsync-2.0.8}/setup.cfg +0 -0
  73. {proxsync-2.0.7 → proxsync-2.0.8}/tests/test_plugin_contracts.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxsync
3
- Version: 2.0.7
3
+ Version: 2.0.8
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/lixiujie85/proxsync
8
- Project-URL: Source, https://github.com/lixiujie85/proxsync
9
- Project-URL: Issues, https://github.com/lixiujie85/proxsync/issues
7
+ Project-URL: Homepage, https://github.com/xiujie2/proxsync
8
+ Project-URL: Source, https://github.com/xiujie2/proxsync
9
+ Project-URL: Issues, https://github.com/xiujie2/proxsync/issues
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Framework :: Django
12
12
  Classifier: Intended Audience :: System Administrators
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxsync
3
- Version: 2.0.7
3
+ Version: 2.0.8
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/lixiujie85/proxsync
8
- Project-URL: Source, https://github.com/lixiujie85/proxsync
9
- Project-URL: Issues, https://github.com/lixiujie85/proxsync/issues
7
+ Project-URL: Homepage, https://github.com/xiujie2/proxsync
8
+ Project-URL: Source, https://github.com/xiujie2/proxsync
9
+ Project-URL: Issues, https://github.com/xiujie2/proxsync/issues
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Framework :: Django
12
12
  Classifier: Intended Audience :: System Administrators
@@ -125,25 +125,12 @@ class OptimizedPVEToNetBoxSync:
125
125
  self.error_log = []
126
126
 
127
127
  # ---------- Telegram ----------
128
- # Max Telegram messages per sync run; excess are counted and reported in the summary.
129
- _MAX_TELEGRAM_PER_SYNC = 20
130
-
131
- def send_telegram_notification(self, message: str) -> bool:
132
- if not getattr(self, 'notify_on_sync', True):
133
- return False
134
- if not self.telegram_bot_token or not self.telegram_chat_id:
135
- return False
136
- # Throttle: cap messages per sync run to avoid flooding on bulk changes.
137
- sent = getattr(self, '_telegram_sent', 0)
138
- if sent >= self._MAX_TELEGRAM_PER_SYNC:
139
- self._telegram_suppressed = getattr(self, '_telegram_suppressed', 0) + 1
140
- return False
128
+ def send_telegram_notification(self, message: str):
141
129
  try:
142
130
  url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
143
131
  payload = {'chat_id': self.telegram_chat_id, 'text': message, 'parse_mode': 'HTML'}
144
132
  response = requests.post(url, data=payload, timeout=10)
145
133
  if response.status_code == 200:
146
- self._telegram_sent = sent + 1
147
134
  print(f"✓ Telegram 通知已發送")
148
135
  return True
149
136
  else:
@@ -174,6 +161,28 @@ class OptimizedPVEToNetBoxSync:
174
161
  self.send_telegram_notification(message)
175
162
  print(f"📧 已發送 IP 衝突通知: {vm_name} - {ip_address}")
176
163
 
164
+ def log_mac_conflict_error(self, vm_name: str, mac_address: str, error_message: str):
165
+ error_info = {
166
+ 'timestamp': time.strftime("%Y-%m-%d %H:%M:%S"),
167
+ 'vm_name': vm_name,
168
+ 'ip_address': mac_address,
169
+ 'error': error_message
170
+ }
171
+ self.error_log.append(error_info)
172
+ message = f"""
173
+ 🚨 <b>PVE-NetBox 同步 MAC 位址衝突警告</b>
174
+
175
+ 📅 時間: {error_info['timestamp']}
176
+ 🖥️ 虛擬機: {vm_name}
177
+ 🔌 MAC 位址: {mac_address}
178
+ ❌ 錯誤: {error_message}
179
+
180
+ ⚠️ 該網卡的 MAC 位址與另一台已同步的 VM 重複(通常是複製 VM 時未重新產生 MAC),
181
+ IP 位址仍會照常同步,但 MAC 關聯需要手動處理
182
+ """
183
+ self.send_telegram_notification(message)
184
+ print(f"📧 已發送 MAC 衝突通知: {vm_name} - {mac_address}")
185
+
177
186
  def send_enhanced_summary(self):
178
187
  if not hasattr(self, 'stats'):
179
188
  return
@@ -212,9 +221,6 @@ class OptimizedPVEToNetBoxSync:
212
221
  message += f"• {error['vm_name']} - {error['ip_address']}\n"
213
222
  if len(self.error_log) > 5:
214
223
  message += f"• ... 還有 {len(self.error_log) - 5} 個錯誤\n"
215
- suppressed = getattr(self, '_telegram_suppressed', 0)
216
- if suppressed:
217
- message += f"\n📵 本輪通知已達上限,{suppressed} 則訊息被壓制(詳見 NetBox 漂移事件記錄)"
218
224
  self.send_telegram_notification(message)
219
225
 
220
226
  # ---------- API 連接 ----------
@@ -301,7 +307,6 @@ class OptimizedPVEToNetBoxSync:
301
307
  self.nb_cache['platforms'][platform.name.lower()] = platform
302
308
  for role in self.nb_api.dcim.device_roles.all():
303
309
  self.nb_cache['roles'][role.name.lower()] = role
304
- self.nb_cache['device_roles'][role.name.lower()] = role
305
310
  for cluster in self.nb_api.virtualization.clusters.all():
306
311
  self.nb_cache['clusters'][str(cluster.id)] = cluster
307
312
  for site in self.nb_api.dcim.sites.all():
@@ -310,6 +315,8 @@ class OptimizedPVEToNetBoxSync:
310
315
  self.nb_cache['manufacturers'][manufacturer.name.lower()] = manufacturer
311
316
  for device_type in self.nb_api.dcim.device_types.all():
312
317
  self.nb_cache['device_types'][device_type.model.lower()] = device_type
318
+ for device_role in self.nb_api.dcim.device_roles.all():
319
+ self.nb_cache['device_roles'][device_role.name.lower()] = device_role
313
320
  for cluster_type in self.nb_api.virtualization.cluster_types.all():
314
321
  self.nb_cache['cluster_types'][cluster_type.name.lower()] = cluster_type
315
322
  elapsed = time.time() - start_time
@@ -366,13 +373,13 @@ class OptimizedPVEToNetBoxSync:
366
373
  vm['node'] = node_name
367
374
  all_vms.append(vm)
368
375
  self.pve_cache['vms_by_node'][node_name] = all_vms
369
- for vm in all_vms:
376
+ for vm in all_vms[:10]:
370
377
  try:
371
378
  if vm['type'] == 'qemu':
372
379
  vm['config'] = self.pve_api.nodes(node_name).qemu(vm['vmid']).config.get()
373
380
  else:
374
381
  vm['config'] = self.pve_api.nodes(node_name).lxc(vm['vmid']).config.get()
375
- except Exception:
382
+ except:
376
383
  pass
377
384
  except Exception as e:
378
385
  print(f" 加載節點 {node_name} 的虛擬機失敗: {e}")
@@ -939,33 +946,43 @@ class OptimizedPVEToNetBoxSync:
939
946
  return {}, {}
940
947
 
941
948
  # ---------- MAC 位址設定(NetBox 4.6+ 使用獨立 MACAddress 物件) ----------
942
- def set_vm_interface_mac(self, vm_interface, mac_address: str):
943
- """Create/update a dcim.MACAddress for the VMInterface and set it as primary."""
944
- mac_lower = mac_address.lower()
945
- existing_mac = self.nb_cache['mac_addresses'].get(mac_lower)
946
- if existing_mac:
947
- # Already exists — ensure it's assigned to this interface and set as primary
948
- needs_save = False
949
- if getattr(existing_mac, 'assigned_object_id', None) != vm_interface.id:
950
- existing_mac.assigned_object_type = 'virtualization.vminterface'
951
- existing_mac.assigned_object_id = vm_interface.id
952
- existing_mac.save()
953
- # Set primary_mac_address on the interface if not already set
954
- current_primary = getattr(vm_interface, 'primary_mac_address', None)
955
- if not current_primary or (hasattr(current_primary, 'id') and current_primary.id != existing_mac.id):
956
- vm_interface.primary_mac_address = existing_mac.id
949
+ def set_vm_interface_mac(self, vm_interface, mac_address: str, vm_name: str = None):
950
+ """Create/update a dcim.MACAddress for the VMInterface and set it as primary.
951
+
952
+ Two different PVE VMs can legitimately report the same hardware-address
953
+ (e.g. a clone whose NIC MAC was never regenerated). NetBox refuses to
954
+ move a MACAddress that is another object's primary MAC, so failures here
955
+ are caught and reported instead of raised — a MAC conflict must never
956
+ block syncing the interface's IP address.
957
+ """
958
+ try:
959
+ mac_lower = mac_address.lower()
960
+ existing_mac = self.nb_cache['mac_addresses'].get(mac_lower)
961
+ if existing_mac:
962
+ # Already exists — ensure it's assigned to this interface and set as primary
963
+ if getattr(existing_mac, 'assigned_object_id', None) != vm_interface.id:
964
+ existing_mac.assigned_object_type = 'virtualization.vminterface'
965
+ existing_mac.assigned_object_id = vm_interface.id
966
+ existing_mac.save()
967
+ # Set primary_mac_address on the interface if not already set
968
+ current_primary = getattr(vm_interface, 'primary_mac_address', None)
969
+ if not current_primary or (hasattr(current_primary, 'id') and current_primary.id != existing_mac.id):
970
+ vm_interface.primary_mac_address = existing_mac.id
971
+ vm_interface.save()
972
+ else:
973
+ # Create new MACAddress assigned to this VMInterface
974
+ mac_obj = self.nb_api.dcim.mac_addresses.create(
975
+ mac_address=mac_address,
976
+ assigned_object_type='virtualization.vminterface',
977
+ assigned_object_id=vm_interface.id,
978
+ )
979
+ self.nb_cache['mac_addresses'][mac_lower] = mac_obj
980
+ # Set it as primary
981
+ vm_interface.primary_mac_address = mac_obj.id
957
982
  vm_interface.save()
958
- else:
959
- # Create new MACAddress assigned to this VMInterface
960
- mac_obj = self.nb_api.dcim.mac_addresses.create(
961
- mac_address=mac_address,
962
- assigned_object_type='virtualization.vminterface',
963
- assigned_object_id=vm_interface.id,
964
- )
965
- self.nb_cache['mac_addresses'][mac_lower] = mac_obj
966
- # Set it as primary
967
- vm_interface.primary_mac_address = mac_obj.id
968
- vm_interface.save()
983
+ except Exception as e:
984
+ print(f" 設定 MAC 位址失敗 {mac_address}: {e}")
985
+ self.log_mac_conflict_error(vm_name or "Unknown", mac_address, str(e))
969
986
 
970
987
  # ---------- VM 介面處理 ----------
971
988
  def process_vm_interfaces(self, vm, vm_config: Dict, agent_interfaces: Dict, mac_to_interface: Dict,
@@ -1001,7 +1018,7 @@ class OptimizedPVEToNetBoxSync:
1001
1018
  if enabled_changed:
1002
1019
  vm_interface.enabled = enabled
1003
1020
  if mac_address:
1004
- self.set_vm_interface_mac(vm_interface, mac_address)
1021
+ self.set_vm_interface_mac(vm_interface, mac_address, vm_name=vm.name)
1005
1022
  # set_vm_interface_mac only saves the interface in some branches;
1006
1023
  # ensure the enabled change is always persisted
1007
1024
  if enabled_changed:
@@ -1015,7 +1032,7 @@ class OptimizedPVEToNetBoxSync:
1015
1032
  vm_interface = self.nb_api.virtualization.interfaces.create(**iface_create_data)
1016
1033
  self.nb_cache['vm_interfaces'].setdefault(vm.id, {})[config_key] = vm_interface
1017
1034
  if mac_address:
1018
- self.set_vm_interface_mac(vm_interface, mac_address)
1035
+ self.set_vm_interface_mac(vm_interface, mac_address, vm_name=vm.name)
1019
1036
  iface_data = {
1020
1037
  'name': config_key, 'mac_address': mac_address or '', 'bridge': bridge_name
1021
1038
  }
@@ -1099,11 +1116,6 @@ class OptimizedPVEToNetBoxSync:
1099
1116
  disks[key] = m.group(1) if m else val_str[:80]
1100
1117
  return disks
1101
1118
 
1102
- @staticmethod
1103
- def _norm_desc(s) -> str:
1104
- """Normalize description: unify line endings, strip whitespace."""
1105
- return (s or '').replace('\r\n', '\n').replace('\r', '\n').strip()
1106
-
1107
1119
  def _write_drift_event(self, vm_name: str, vmid: int, drift_type: str,
1108
1120
  field_name: str, old_value: str, new_value: str,
1109
1121
  notified: bool = False):
@@ -1126,13 +1138,13 @@ class OptimizedPVEToNetBoxSync:
1126
1138
  except Exception as e:
1127
1139
  print(f" ⚠ 無法寫入漂移事件記錄: {e}")
1128
1140
 
1129
- def detect_new_vm(self, vm_id: int, vm_name: str, node_name: str, last_config=None):
1141
+ def detect_new_vm(self, vm_id: int, vm_name: str, node_name: str):
1130
1142
  """若此 vmid 在先前同步中不存在(但叢集有先前記錄),視為新增 VM。"""
1131
1143
  if not self.enhanced_mode or not self.state_db:
1132
1144
  return
1133
1145
  if not self._known_vmids:
1134
1146
  return # 首次同步,無先前記錄,不觸發
1135
- if last_config:
1147
+ if self.state_db.get_last_vm_config(vm_id, self.cluster_name):
1136
1148
  return # 已有記錄,不是新增
1137
1149
  print(f"🆕 VM {vm_name} 新增偵測 (ID: {vm_id}, 節點: {node_name})")
1138
1150
  self.stats['config_drifts_detected'] += 1
@@ -1271,29 +1283,15 @@ class OptimizedPVEToNetBoxSync:
1271
1283
  print(f" 🧹 Log 清理:刪除 {d_del} 筆漂移事件、{t_del} 筆操作記錄(>{days} 天)")
1272
1284
  except Exception as exc:
1273
1285
  print(f" ⚠️ Log 清理失敗: {exc}")
1274
- # Also clean up state_db vm_config_history (keep latest row per VM, remove old history)
1275
- if self.state_db:
1276
- try:
1277
- from pve_sync_plugin.models import PvePluginSettings
1278
- days = PvePluginSettings.load().log_retention_days
1279
- if days:
1280
- h_del = self.state_db.cleanup_old_history(self.cluster_name, days)
1281
- if h_del:
1282
- print(f" 🧹 state_db 清理:刪除 {h_del} 筆舊配置歷史(>{days} 天)")
1283
- except Exception as exc:
1284
- print(f" ⚠️ state_db 歷史清理失敗: {exc}")
1285
1286
 
1286
1287
  def sync_vm_tasks(self):
1287
1288
  """從 PVE 各節點抓取 VM 操作記錄(建立/刪除/Clone/遷移)並同步到 NetBox。"""
1288
- import calendar
1289
- import datetime as _dt
1290
1289
  try:
1291
1290
  from django.utils import timezone
1292
1291
  from pve_sync_plugin.models import PveVmTaskLog
1293
1292
  except ImportError:
1294
1293
  return
1295
1294
 
1296
- _utc = _dt.timezone.utc
1297
1295
  VM_TASK_TYPES = {
1298
1296
  "qmcreate", "qmdestroy", "qmclone", "qmmigrate", "qmrestore",
1299
1297
  "qmstart", "qmstop", "qmshutdown", "qmreboot",
@@ -1327,18 +1325,10 @@ class OptimizedPVEToNetBoxSync:
1327
1325
  cluster_name=self.cluster_name
1328
1326
  ).order_by("-start_time").values_list("start_time", flat=True).first()
1329
1327
  if latest:
1328
+ import calendar
1330
1329
  since_ts = int(calendar.timegm(latest.utctimetuple())) + 1
1331
1330
  else:
1332
1331
  since_ts = int(time.time()) - 30 * 86400
1333
-
1334
- # Pre-fetch existing UPIDs in the window → O(1) lookup instead of N+1 queries
1335
- since_dt = _dt.datetime.fromtimestamp(since_ts, tz=_utc)
1336
- existing_upids: set = set(
1337
- PveVmTaskLog.objects
1338
- .filter(cluster_name=self.cluster_name, start_time__gte=since_dt)
1339
- .values_list("upid", flat=True)
1340
- )
1341
- new_upids: set = set() # UPIDs inserted this run (avoid duplicates within same run)
1342
1332
  new_count = 0
1343
1333
 
1344
1334
  for node_name in self.pve_cache.get("vms_by_node", {}):
@@ -1362,8 +1352,8 @@ class OptimizedPVEToNetBoxSync:
1362
1352
  except (ValueError, TypeError):
1363
1353
  continue
1364
1354
 
1365
- # UPID 去重(使用預先撈取的集合,避免 N+1 查詢)
1366
- if upid in existing_upids or upid in new_upids:
1355
+ # UPID 去重
1356
+ if PveVmTaskLog.objects.filter(upid=upid).exists():
1367
1357
  continue
1368
1358
 
1369
1359
  task_type = task.get("type")
@@ -1373,6 +1363,8 @@ class OptimizedPVEToNetBoxSync:
1373
1363
  status = task.get("status", "") or ""
1374
1364
  vm_name = vmid_to_name.get(vmid, f"VM-{vmid}")
1375
1365
 
1366
+ import datetime as _dt
1367
+ _utc = _dt.timezone.utc
1376
1368
  start_dt = _dt.datetime.fromtimestamp(start_ts, tz=_utc) if start_ts else timezone.now()
1377
1369
  end_dt = _dt.datetime.fromtimestamp(end_ts, tz=_utc) if end_ts else None
1378
1370
 
@@ -1389,7 +1381,6 @@ class OptimizedPVEToNetBoxSync:
1389
1381
  status=status,
1390
1382
  notified_telegram=False,
1391
1383
  )
1392
- new_upids.add(upid)
1393
1384
  new_count += 1
1394
1385
 
1395
1386
  # Telegram 通知(僅近期且已完成的操作)
@@ -1417,12 +1408,11 @@ class OptimizedPVEToNetBoxSync:
1417
1408
 
1418
1409
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1419
1410
  current_tags: List[str], network_interfaces: List[Dict] = None,
1420
- current_node: str = None, last_config=None) -> List[Dict]:
1411
+ current_node: str = None) -> List[Dict]:
1421
1412
  if not self.enhanced_mode or not self.state_db:
1422
1413
  return []
1423
1414
  drifts = []
1424
- if last_config is None:
1425
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1415
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1426
1416
  if not last_config:
1427
1417
  return drifts
1428
1418
  current_memory = int(vm_config.get('memory', 0))
@@ -1503,8 +1493,8 @@ class OptimizedPVEToNetBoxSync:
1503
1493
  self._write_drift_event(vm_name, vm_id, 'disk_change', k, old_v, new_v, notified=True)
1504
1494
 
1505
1495
  # VM 描述變更偵測
1506
- current_desc = self._norm_desc(vm_config.get('description'))[:500].strip()
1507
- old_desc = self._norm_desc(last_config.get('description'))[:500].strip()
1496
+ current_desc = (vm_config.get('description') or '').strip()[:500]
1497
+ old_desc = (last_config.get('description') or '').strip()
1508
1498
  if old_desc and old_desc != current_desc:
1509
1499
  self.stats['config_drifts_detected'] += 1
1510
1500
  print(f"📝 VM {vm_name} 描述變更")
@@ -1544,7 +1534,7 @@ class OptimizedPVEToNetBoxSync:
1544
1534
  return drifts
1545
1535
 
1546
1536
  def should_sync_vm(self, vm_id: int, vm_config: Dict[str, Any], tags: List[str],
1547
- network_interfaces: List[Dict] = None, last_config=None) -> bool:
1537
+ network_interfaces: List[Dict] = None) -> bool:
1548
1538
  if not self.sync_config.get('incremental', True):
1549
1539
  return True
1550
1540
  if self.sync_config.get('force_full_sync', False):
@@ -1552,35 +1542,34 @@ class OptimizedPVEToNetBoxSync:
1552
1542
  current_hash = compute_config_hash(vm_config, tags, network_interfaces)
1553
1543
  if not self.state_db:
1554
1544
  return True
1555
- if last_config is None:
1556
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1545
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1557
1546
  if not last_config:
1558
1547
  return True
1559
1548
  if current_hash != last_config.get('config_hash'):
1560
1549
  return True
1561
1550
  return False
1562
1551
 
1563
- def detect_tag_changes(self, vm_id: int, vm_name: str, current_tags: List[str],
1564
- last_config=None) -> List[Dict]:
1552
+ def detect_tag_changes(self, vm_id: int, vm_name: str, current_tags: List[str]) -> List[Dict]:
1565
1553
  if not self.enhanced_mode or not self.state_db:
1566
1554
  return []
1567
- if last_config is None:
1568
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1555
+ changes = []
1556
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1569
1557
  if not last_config:
1570
- return []
1558
+ return changes
1571
1559
  old_tags = set(last_config.get('tags', []))
1572
1560
  new_tags = set(current_tags)
1573
1561
  added = new_tags - old_tags
1574
1562
  removed = old_tags - new_tags
1575
- changes = []
1576
1563
  for tag in added:
1577
1564
  changes.append({'type': 'added', 'tag': tag})
1578
1565
  self.stats['tag_changes'] += 1
1579
1566
  print(f"🏷️ VM {vm_name} 新增標籤: {tag}")
1567
+ self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', '', tag, notified=False)
1580
1568
  for tag in removed:
1581
1569
  changes.append({'type': 'removed', 'tag': tag})
1582
1570
  self.stats['tag_changes'] += 1
1583
1571
  print(f"🏷️ VM {vm_name} 移除標籤: {tag}")
1572
+ self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', tag, '', notified=False)
1584
1573
  if changes:
1585
1574
  added_list = [c['tag'] for c in changes if c['type'] == 'added']
1586
1575
  removed_list = [c['tag'] for c in changes if c['type'] == 'removed']
@@ -1596,12 +1585,7 @@ class OptimizedPVEToNetBoxSync:
1596
1585
  f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1597
1586
  f"{detail}\n請確認是否為預期變更。"
1598
1587
  )
1599
- notified = self.send_telegram_notification(tag_msg)
1600
- for c in changes:
1601
- if c['type'] == 'added':
1602
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', '', c['tag'], notified=notified)
1603
- else:
1604
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', c['tag'], '', notified=notified)
1588
+ self.send_telegram_notification(tag_msg)
1605
1589
  return changes
1606
1590
 
1607
1591
  def detect_ip_change(self, vm_id: int, vm_name: str, current_ip: str):
@@ -1669,16 +1653,10 @@ class OptimizedPVEToNetBoxSync:
1669
1653
  tag_ids.append(tag.id)
1670
1654
  except Exception as e:
1671
1655
  print(f" 建立標籤失敗 {tag_name}: {e}")
1672
- # Read last_config once; share with detect_new_vm / detect_tag_changes /
1673
- # detect_config_drift / should_sync_vm to avoid repeated SQLite round-trips.
1674
- last_config = (
1675
- self.state_db.get_last_vm_config(int(vm_id), self.cluster_name)
1676
- if (self.enhanced_mode and self.state_db) else None
1677
- )
1678
1656
  if self.enhanced_mode:
1679
- self.detect_new_vm(int(vm_id), original_vm_name, node_name, last_config=last_config)
1657
+ self.detect_new_vm(int(vm_id), original_vm_name, node_name)
1680
1658
  if self.enhanced_mode and tag_names:
1681
- self.detect_tag_changes(int(vm_id), original_vm_name, tag_names, last_config=last_config)
1659
+ self.detect_tag_changes(int(vm_id), original_vm_name, tag_names)
1682
1660
  vm_status = vm_data.get('status', '')
1683
1661
  if tag_names and vm_status in ('running', 'stopped'):
1684
1662
  first_tag = tag_names[0].lower()
@@ -1761,9 +1739,8 @@ class OptimizedPVEToNetBoxSync:
1761
1739
  })
1762
1740
  if self.enhanced_mode:
1763
1741
  self.detect_config_drift(int(vm_id), original_vm_name, vm_config, tag_names,
1764
- network_interfaces, current_node=node_name, last_config=last_config)
1765
- if not force and not self.should_sync_vm(int(vm_id), vm_config, tag_names, network_interfaces,
1766
- last_config=last_config):
1742
+ network_interfaces, current_node=node_name)
1743
+ if not force and not self.should_sync_vm(int(vm_id), vm_config, tag_names, network_interfaces):
1767
1744
  cached_vm = self.nb_cache['virtual_machines_by_serial'].get(f"{vm_id}::{cluster['id']}")
1768
1745
  if cached_vm is None:
1769
1746
  # VM 在 NetBox 中不存在(例如隨節點被連帶刪除),
@@ -1804,7 +1781,7 @@ class OptimizedPVEToNetBoxSync:
1804
1781
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1805
1782
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1806
1783
  node=node_name, primary_ip=cur_ip, vm_name=original_vm_name,
1807
- description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1784
+ description=(vm_config.get('description') or '').strip()[:500],
1808
1785
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1809
1786
  )
1810
1787
  except Exception as _snap_err:
@@ -1861,7 +1838,7 @@ class OptimizedPVEToNetBoxSync:
1861
1838
  update_data = {
1862
1839
  'name': vm_name, 'cluster': cluster['id'], 'device': device.id, 'role': role_id,
1863
1840
  'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)), 'status': status,
1864
- 'description': self._norm_desc(vm_config.get('description'))[:200], 'platform': platform_id,
1841
+ 'description': (vm_config.get('description') or '')[:200], 'platform': platform_id,
1865
1842
  'start_on_boot': boot_choice
1866
1843
  }
1867
1844
  if tag_ids:
@@ -1880,7 +1857,7 @@ class OptimizedPVEToNetBoxSync:
1880
1857
  vm_data_dict = {
1881
1858
  'serial': vm_id, 'name': vm_name, 'cluster': cluster['id'], 'device': device.id,
1882
1859
  'role': role_id, 'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)),
1883
- 'status': status, 'description': self._norm_desc(vm_config.get('description'))[:200],
1860
+ 'status': status, 'description': (vm_config.get('description') or '')[:200],
1884
1861
  'platform': platform_id, 'start_on_boot': boot_choice
1885
1862
  }
1886
1863
  if tag_ids:
@@ -1917,7 +1894,7 @@ class OptimizedPVEToNetBoxSync:
1917
1894
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1918
1895
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1919
1896
  node=node_name, primary_ip=primary_ip_str, vm_name=original_vm_name,
1920
- description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1897
+ description=(vm_config.get('description') or '').strip()[:500],
1921
1898
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1922
1899
  )
1923
1900
  return True
@@ -1942,7 +1919,7 @@ class OptimizedPVEToNetBoxSync:
1942
1919
  node=node_name,
1943
1920
  primary_ip=_ip,
1944
1921
  vm_name=original_vm_name,
1945
- description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1922
+ description=(vm_config.get('description') or '').strip()[:500],
1946
1923
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1947
1924
  )
1948
1925
  except Exception:
@@ -1985,18 +1962,6 @@ class OptimizedPVEToNetBoxSync:
1985
1962
  print("=" * 50)
1986
1963
  if self.enhanced_mode and self.state_db:
1987
1964
  self.sync_log_id = self.state_db.start_sync_log(self.cluster_name)
1988
- # Load notify_on_sync from PveClusterConfig (DB setting takes precedence over default True)
1989
- self.notify_on_sync = True
1990
- try:
1991
- from pve_sync_plugin.models import PveClusterConfig
1992
- cfg = PveClusterConfig.objects.filter(name=self.cluster_name).first()
1993
- if cfg is not None:
1994
- self.notify_on_sync = cfg.notify_on_sync
1995
- except Exception:
1996
- pass
1997
- # Reset per-sync Telegram counters
1998
- self._telegram_sent = 0
1999
- self._telegram_suppressed = 0
2000
1965
  sync_mode = "增量" if self.sync_config.get('incremental', True) else "全量"
2001
1966
  start_message = f"""
2002
1967
  🔄 <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.0.7"
7
+ version = "2.0.8"
8
8
  description = "NetBox plugin for Proxmox VE inventory synchronization"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -36,9 +36,9 @@ dependencies = [
36
36
  ]
37
37
 
38
38
  [project.urls]
39
- Homepage = "https://github.com/lixiujie85/proxsync"
40
- Source = "https://github.com/lixiujie85/proxsync"
41
- Issues = "https://github.com/lixiujie85/proxsync/issues"
39
+ Homepage = "https://github.com/xiujie2/proxsync"
40
+ Source = "https://github.com/xiujie2/proxsync"
41
+ Issues = "https://github.com/xiujie2/proxsync/issues"
42
42
 
43
43
  [project.optional-dependencies]
44
44
  standalone = [
@@ -154,7 +154,7 @@ class StateDB:
154
154
  with sqlite3.connect(self.db_path) as conn:
155
155
  conn.execute(
156
156
  """
157
- INSERT OR REPLACE INTO vm_config_history
157
+ INSERT 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,28 +256,6 @@ 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
-
281
259
  # ========== 节点状态历史 ==========
282
260
 
283
261
  def save_node_status(self, node_name: str, cluster_name: str, status: str):
@@ -529,7 +507,7 @@ def compute_config_hash(vm_config: Dict[str, Any], tags: List[str], network_inte
529
507
  'ostype': vm_config.get('ostype', ''),
530
508
  'cores': vm_config.get('cores', 1),
531
509
  'sockets': vm_config.get('sockets', 1),
532
- 'description': (vm_config.get('description') or '').replace('\r\n', '\n').replace('\r', '\n').strip()[:200].strip(),
510
+ 'description': vm_config.get('description', '')[:200], # 限制长度
533
511
  'tags': sorted(tags), # 排序以确保一致性
534
512
  'networks': net_config # 添加网络接口配置
535
513
  }
File without changes
File without changes
File without changes
File without changes
File without changes