proxsync 2.0.5__tar.gz → 2.0.7__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.5/proxsync.egg-info → proxsync-2.0.7}/PKG-INFO +1 -1
  2. {proxsync-2.0.5 → proxsync-2.0.7/proxsync.egg-info}/PKG-INFO +1 -1
  3. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync.py +94 -32
  4. {proxsync-2.0.5 → proxsync-2.0.7}/pyproject.toml +1 -1
  5. {proxsync-2.0.5 → proxsync-2.0.7}/state_db.py +23 -1
  6. {proxsync-2.0.5 → proxsync-2.0.7}/MANIFEST.in +0 -0
  7. {proxsync-2.0.5 → proxsync-2.0.7}/README.md +0 -0
  8. {proxsync-2.0.5 → proxsync-2.0.7}/config.py +0 -0
  9. {proxsync-2.0.5 → proxsync-2.0.7}/pbs_sync.py +0 -0
  10. {proxsync-2.0.5 → proxsync-2.0.7}/proxsync.egg-info/SOURCES.txt +0 -0
  11. {proxsync-2.0.5 → proxsync-2.0.7}/proxsync.egg-info/dependency_links.txt +0 -0
  12. {proxsync-2.0.5 → proxsync-2.0.7}/proxsync.egg-info/requires.txt +0 -0
  13. {proxsync-2.0.5 → proxsync-2.0.7}/proxsync.egg-info/top_level.txt +0 -0
  14. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/__init__.py +0 -0
  15. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/api/__init__.py +0 -0
  16. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/api/serializers.py +0 -0
  17. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/api/urls.py +0 -0
  18. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/api/views.py +0 -0
  19. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/apps.py +0 -0
  20. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/choices.py +0 -0
  21. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/filtersets.py +0 -0
  22. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/forms.py +0 -0
  23. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/management/__init__.py +0 -0
  24. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/management/commands/__init__.py +0 -0
  25. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  26. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  27. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  28. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  29. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  30. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  31. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  32. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  33. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  34. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  35. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  36. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  37. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  38. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  39. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  40. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +0 -0
  41. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0015_pvevmtasklog.py +0 -0
  42. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py +0 -0
  43. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/migrations/__init__.py +0 -0
  44. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/models.py +0 -0
  45. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/navigation.py +0 -0
  46. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/signals.py +0 -0
  47. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/sync/__init__.py +0 -0
  48. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/sync/config_bridge.py +0 -0
  49. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/sync/engine.py +0 -0
  50. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/tables.py +0 -0
  51. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/tasks.py +0 -0
  52. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/template_content.py +0 -0
  53. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  54. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  55. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html +0 -0
  56. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  57. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  58. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  59. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  60. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +0 -0
  61. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html +0 -0
  62. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  63. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  64. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +0 -0
  65. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +0 -0
  66. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templatetags/__init__.py +0 -0
  67. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/templatetags/pve_sync_tags.py +0 -0
  68. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/urls.py +0 -0
  69. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/utils.py +0 -0
  70. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/view_helpers.py +0 -0
  71. {proxsync-2.0.5 → proxsync-2.0.7}/pve_sync_plugin/views.py +0 -0
  72. {proxsync-2.0.5 → proxsync-2.0.7}/setup.cfg +0 -0
  73. {proxsync-2.0.5 → proxsync-2.0.7}/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.5
3
+ Version: 2.0.7
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxsync
3
- Version: 2.0.5
3
+ Version: 2.0.7
4
4
  Summary: NetBox plugin for Proxmox VE inventory synchronization
5
5
  Author-email: lixiujie85 <lixiujie85@gmail.com>
6
6
  License-Expression: MIT
@@ -125,12 +125,25 @@ class OptimizedPVEToNetBoxSync:
125
125
  self.error_log = []
126
126
 
127
127
  # ---------- Telegram ----------
128
- def send_telegram_notification(self, message: str):
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
129
141
  try:
130
142
  url = f"https://api.telegram.org/bot{self.telegram_bot_token}/sendMessage"
131
143
  payload = {'chat_id': self.telegram_chat_id, 'text': message, 'parse_mode': 'HTML'}
132
144
  response = requests.post(url, data=payload, timeout=10)
133
145
  if response.status_code == 200:
146
+ self._telegram_sent = sent + 1
134
147
  print(f"✓ Telegram 通知已發送")
135
148
  return True
136
149
  else:
@@ -199,6 +212,9 @@ class OptimizedPVEToNetBoxSync:
199
212
  message += f"• {error['vm_name']} - {error['ip_address']}\n"
200
213
  if len(self.error_log) > 5:
201
214
  message += f"• ... 還有 {len(self.error_log) - 5} 個錯誤\n"
215
+ suppressed = getattr(self, '_telegram_suppressed', 0)
216
+ if suppressed:
217
+ message += f"\n📵 本輪通知已達上限,{suppressed} 則訊息被壓制(詳見 NetBox 漂移事件記錄)"
202
218
  self.send_telegram_notification(message)
203
219
 
204
220
  # ---------- API 連接 ----------
@@ -285,6 +301,7 @@ class OptimizedPVEToNetBoxSync:
285
301
  self.nb_cache['platforms'][platform.name.lower()] = platform
286
302
  for role in self.nb_api.dcim.device_roles.all():
287
303
  self.nb_cache['roles'][role.name.lower()] = role
304
+ self.nb_cache['device_roles'][role.name.lower()] = role
288
305
  for cluster in self.nb_api.virtualization.clusters.all():
289
306
  self.nb_cache['clusters'][str(cluster.id)] = cluster
290
307
  for site in self.nb_api.dcim.sites.all():
@@ -293,8 +310,6 @@ class OptimizedPVEToNetBoxSync:
293
310
  self.nb_cache['manufacturers'][manufacturer.name.lower()] = manufacturer
294
311
  for device_type in self.nb_api.dcim.device_types.all():
295
312
  self.nb_cache['device_types'][device_type.model.lower()] = device_type
296
- for device_role in self.nb_api.dcim.device_roles.all():
297
- self.nb_cache['device_roles'][device_role.name.lower()] = device_role
298
313
  for cluster_type in self.nb_api.virtualization.cluster_types.all():
299
314
  self.nb_cache['cluster_types'][cluster_type.name.lower()] = cluster_type
300
315
  elapsed = time.time() - start_time
@@ -351,13 +366,13 @@ class OptimizedPVEToNetBoxSync:
351
366
  vm['node'] = node_name
352
367
  all_vms.append(vm)
353
368
  self.pve_cache['vms_by_node'][node_name] = all_vms
354
- for vm in all_vms[:10]:
369
+ for vm in all_vms:
355
370
  try:
356
371
  if vm['type'] == 'qemu':
357
372
  vm['config'] = self.pve_api.nodes(node_name).qemu(vm['vmid']).config.get()
358
373
  else:
359
374
  vm['config'] = self.pve_api.nodes(node_name).lxc(vm['vmid']).config.get()
360
- except:
375
+ except Exception:
361
376
  pass
362
377
  except Exception as e:
363
378
  print(f" 加載節點 {node_name} 的虛擬機失敗: {e}")
@@ -1111,13 +1126,13 @@ class OptimizedPVEToNetBoxSync:
1111
1126
  except Exception as e:
1112
1127
  print(f" ⚠ 無法寫入漂移事件記錄: {e}")
1113
1128
 
1114
- def detect_new_vm(self, vm_id: int, vm_name: str, node_name: str):
1129
+ def detect_new_vm(self, vm_id: int, vm_name: str, node_name: str, last_config=None):
1115
1130
  """若此 vmid 在先前同步中不存在(但叢集有先前記錄),視為新增 VM。"""
1116
1131
  if not self.enhanced_mode or not self.state_db:
1117
1132
  return
1118
1133
  if not self._known_vmids:
1119
1134
  return # 首次同步,無先前記錄,不觸發
1120
- if self.state_db.get_last_vm_config(vm_id, self.cluster_name):
1135
+ if last_config:
1121
1136
  return # 已有記錄,不是新增
1122
1137
  print(f"🆕 VM {vm_name} 新增偵測 (ID: {vm_id}, 節點: {node_name})")
1123
1138
  self.stats['config_drifts_detected'] += 1
@@ -1256,15 +1271,29 @@ class OptimizedPVEToNetBoxSync:
1256
1271
  print(f" 🧹 Log 清理:刪除 {d_del} 筆漂移事件、{t_del} 筆操作記錄(>{days} 天)")
1257
1272
  except Exception as exc:
1258
1273
  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}")
1259
1285
 
1260
1286
  def sync_vm_tasks(self):
1261
1287
  """從 PVE 各節點抓取 VM 操作記錄(建立/刪除/Clone/遷移)並同步到 NetBox。"""
1288
+ import calendar
1289
+ import datetime as _dt
1262
1290
  try:
1263
1291
  from django.utils import timezone
1264
1292
  from pve_sync_plugin.models import PveVmTaskLog
1265
1293
  except ImportError:
1266
1294
  return
1267
1295
 
1296
+ _utc = _dt.timezone.utc
1268
1297
  VM_TASK_TYPES = {
1269
1298
  "qmcreate", "qmdestroy", "qmclone", "qmmigrate", "qmrestore",
1270
1299
  "qmstart", "qmstop", "qmshutdown", "qmreboot",
@@ -1298,10 +1327,18 @@ class OptimizedPVEToNetBoxSync:
1298
1327
  cluster_name=self.cluster_name
1299
1328
  ).order_by("-start_time").values_list("start_time", flat=True).first()
1300
1329
  if latest:
1301
- import calendar
1302
1330
  since_ts = int(calendar.timegm(latest.utctimetuple())) + 1
1303
1331
  else:
1304
1332
  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)
1305
1342
  new_count = 0
1306
1343
 
1307
1344
  for node_name in self.pve_cache.get("vms_by_node", {}):
@@ -1325,8 +1362,8 @@ class OptimizedPVEToNetBoxSync:
1325
1362
  except (ValueError, TypeError):
1326
1363
  continue
1327
1364
 
1328
- # UPID 去重
1329
- if PveVmTaskLog.objects.filter(upid=upid).exists():
1365
+ # UPID 去重(使用預先撈取的集合,避免 N+1 查詢)
1366
+ if upid in existing_upids or upid in new_upids:
1330
1367
  continue
1331
1368
 
1332
1369
  task_type = task.get("type")
@@ -1336,8 +1373,6 @@ class OptimizedPVEToNetBoxSync:
1336
1373
  status = task.get("status", "") or ""
1337
1374
  vm_name = vmid_to_name.get(vmid, f"VM-{vmid}")
1338
1375
 
1339
- import datetime as _dt
1340
- _utc = _dt.timezone.utc
1341
1376
  start_dt = _dt.datetime.fromtimestamp(start_ts, tz=_utc) if start_ts else timezone.now()
1342
1377
  end_dt = _dt.datetime.fromtimestamp(end_ts, tz=_utc) if end_ts else None
1343
1378
 
@@ -1354,6 +1389,7 @@ class OptimizedPVEToNetBoxSync:
1354
1389
  status=status,
1355
1390
  notified_telegram=False,
1356
1391
  )
1392
+ new_upids.add(upid)
1357
1393
  new_count += 1
1358
1394
 
1359
1395
  # Telegram 通知(僅近期且已完成的操作)
@@ -1381,11 +1417,12 @@ class OptimizedPVEToNetBoxSync:
1381
1417
 
1382
1418
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1383
1419
  current_tags: List[str], network_interfaces: List[Dict] = None,
1384
- current_node: str = None) -> List[Dict]:
1420
+ current_node: str = None, last_config=None) -> List[Dict]:
1385
1421
  if not self.enhanced_mode or not self.state_db:
1386
1422
  return []
1387
1423
  drifts = []
1388
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1424
+ if last_config is None:
1425
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1389
1426
  if not last_config:
1390
1427
  return drifts
1391
1428
  current_memory = int(vm_config.get('memory', 0))
@@ -1466,8 +1503,8 @@ class OptimizedPVEToNetBoxSync:
1466
1503
  self._write_drift_event(vm_name, vm_id, 'disk_change', k, old_v, new_v, notified=True)
1467
1504
 
1468
1505
  # VM 描述變更偵測
1469
- current_desc = self._norm_desc(vm_config.get('description'))[:500]
1470
- old_desc = self._norm_desc(last_config.get('description'))
1506
+ current_desc = self._norm_desc(vm_config.get('description'))[:500].strip()
1507
+ old_desc = self._norm_desc(last_config.get('description'))[:500].strip()
1471
1508
  if old_desc and old_desc != current_desc:
1472
1509
  self.stats['config_drifts_detected'] += 1
1473
1510
  print(f"📝 VM {vm_name} 描述變更")
@@ -1507,7 +1544,7 @@ class OptimizedPVEToNetBoxSync:
1507
1544
  return drifts
1508
1545
 
1509
1546
  def should_sync_vm(self, vm_id: int, vm_config: Dict[str, Any], tags: List[str],
1510
- network_interfaces: List[Dict] = None) -> bool:
1547
+ network_interfaces: List[Dict] = None, last_config=None) -> bool:
1511
1548
  if not self.sync_config.get('incremental', True):
1512
1549
  return True
1513
1550
  if self.sync_config.get('force_full_sync', False):
@@ -1515,34 +1552,35 @@ class OptimizedPVEToNetBoxSync:
1515
1552
  current_hash = compute_config_hash(vm_config, tags, network_interfaces)
1516
1553
  if not self.state_db:
1517
1554
  return True
1518
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1555
+ if last_config is None:
1556
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1519
1557
  if not last_config:
1520
1558
  return True
1521
1559
  if current_hash != last_config.get('config_hash'):
1522
1560
  return True
1523
1561
  return False
1524
1562
 
1525
- def detect_tag_changes(self, vm_id: int, vm_name: str, current_tags: List[str]) -> List[Dict]:
1563
+ def detect_tag_changes(self, vm_id: int, vm_name: str, current_tags: List[str],
1564
+ last_config=None) -> List[Dict]:
1526
1565
  if not self.enhanced_mode or not self.state_db:
1527
1566
  return []
1528
- changes = []
1529
- last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1567
+ if last_config is None:
1568
+ last_config = self.state_db.get_last_vm_config(vm_id, self.cluster_name)
1530
1569
  if not last_config:
1531
- return changes
1570
+ return []
1532
1571
  old_tags = set(last_config.get('tags', []))
1533
1572
  new_tags = set(current_tags)
1534
1573
  added = new_tags - old_tags
1535
1574
  removed = old_tags - new_tags
1575
+ changes = []
1536
1576
  for tag in added:
1537
1577
  changes.append({'type': 'added', 'tag': tag})
1538
1578
  self.stats['tag_changes'] += 1
1539
1579
  print(f"🏷️ VM {vm_name} 新增標籤: {tag}")
1540
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', '', tag, notified=False)
1541
1580
  for tag in removed:
1542
1581
  changes.append({'type': 'removed', 'tag': tag})
1543
1582
  self.stats['tag_changes'] += 1
1544
1583
  print(f"🏷️ VM {vm_name} 移除標籤: {tag}")
1545
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', tag, '', notified=False)
1546
1584
  if changes:
1547
1585
  added_list = [c['tag'] for c in changes if c['type'] == 'added']
1548
1586
  removed_list = [c['tag'] for c in changes if c['type'] == 'removed']
@@ -1558,7 +1596,12 @@ class OptimizedPVEToNetBoxSync:
1558
1596
  f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1559
1597
  f"{detail}\n請確認是否為預期變更。"
1560
1598
  )
1561
- self.send_telegram_notification(tag_msg)
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)
1562
1605
  return changes
1563
1606
 
1564
1607
  def detect_ip_change(self, vm_id: int, vm_name: str, current_ip: str):
@@ -1626,10 +1669,16 @@ class OptimizedPVEToNetBoxSync:
1626
1669
  tag_ids.append(tag.id)
1627
1670
  except Exception as e:
1628
1671
  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
+ )
1629
1678
  if self.enhanced_mode:
1630
- self.detect_new_vm(int(vm_id), original_vm_name, node_name)
1679
+ self.detect_new_vm(int(vm_id), original_vm_name, node_name, last_config=last_config)
1631
1680
  if self.enhanced_mode and tag_names:
1632
- self.detect_tag_changes(int(vm_id), original_vm_name, tag_names)
1681
+ self.detect_tag_changes(int(vm_id), original_vm_name, tag_names, last_config=last_config)
1633
1682
  vm_status = vm_data.get('status', '')
1634
1683
  if tag_names and vm_status in ('running', 'stopped'):
1635
1684
  first_tag = tag_names[0].lower()
@@ -1712,8 +1761,9 @@ class OptimizedPVEToNetBoxSync:
1712
1761
  })
1713
1762
  if self.enhanced_mode:
1714
1763
  self.detect_config_drift(int(vm_id), original_vm_name, vm_config, tag_names,
1715
- network_interfaces, current_node=node_name)
1716
- if not force and not self.should_sync_vm(int(vm_id), vm_config, tag_names, network_interfaces):
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):
1717
1767
  cached_vm = self.nb_cache['virtual_machines_by_serial'].get(f"{vm_id}::{cluster['id']}")
1718
1768
  if cached_vm is None:
1719
1769
  # VM 在 NetBox 中不存在(例如隨節點被連帶刪除),
@@ -1754,7 +1804,7 @@ class OptimizedPVEToNetBoxSync:
1754
1804
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1755
1805
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1756
1806
  node=node_name, primary_ip=cur_ip, vm_name=original_vm_name,
1757
- description=self._norm_desc(vm_config.get('description'))[:500],
1807
+ description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1758
1808
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1759
1809
  )
1760
1810
  except Exception as _snap_err:
@@ -1867,7 +1917,7 @@ class OptimizedPVEToNetBoxSync:
1867
1917
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1868
1918
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1869
1919
  node=node_name, primary_ip=primary_ip_str, vm_name=original_vm_name,
1870
- description=self._norm_desc(vm_config.get('description'))[:500],
1920
+ description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1871
1921
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1872
1922
  )
1873
1923
  return True
@@ -1892,7 +1942,7 @@ class OptimizedPVEToNetBoxSync:
1892
1942
  node=node_name,
1893
1943
  primary_ip=_ip,
1894
1944
  vm_name=original_vm_name,
1895
- description=self._norm_desc(vm_config.get('description'))[:500],
1945
+ description=self._norm_desc(vm_config.get('description'))[:500].strip(),
1896
1946
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1897
1947
  )
1898
1948
  except Exception:
@@ -1935,6 +1985,18 @@ class OptimizedPVEToNetBoxSync:
1935
1985
  print("=" * 50)
1936
1986
  if self.enhanced_mode and self.state_db:
1937
1987
  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
1938
2000
  sync_mode = "增量" if self.sync_config.get('incremental', True) else "全量"
1939
2001
  start_message = f"""
1940
2002
  🔄 <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.5"
7
+ version = "2.0.7"
8
8
  description = "NetBox plugin for Proxmox VE inventory synchronization"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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') or '').replace('\r\n', '\n').replace('\r', '\n').strip()[: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
File without changes