proxsync 2.0.4__tar.gz → 2.0.6__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.4/proxsync.egg-info → proxsync-2.0.6}/PKG-INFO +1 -1
  2. {proxsync-2.0.4 → proxsync-2.0.6/proxsync.egg-info}/PKG-INFO +1 -1
  3. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync.py +101 -34
  4. {proxsync-2.0.4 → proxsync-2.0.6}/pyproject.toml +1 -1
  5. {proxsync-2.0.4 → proxsync-2.0.6}/state_db.py +24 -2
  6. {proxsync-2.0.4 → proxsync-2.0.6}/MANIFEST.in +0 -0
  7. {proxsync-2.0.4 → proxsync-2.0.6}/README.md +0 -0
  8. {proxsync-2.0.4 → proxsync-2.0.6}/config.py +0 -0
  9. {proxsync-2.0.4 → proxsync-2.0.6}/pbs_sync.py +0 -0
  10. {proxsync-2.0.4 → proxsync-2.0.6}/proxsync.egg-info/SOURCES.txt +0 -0
  11. {proxsync-2.0.4 → proxsync-2.0.6}/proxsync.egg-info/dependency_links.txt +0 -0
  12. {proxsync-2.0.4 → proxsync-2.0.6}/proxsync.egg-info/requires.txt +0 -0
  13. {proxsync-2.0.4 → proxsync-2.0.6}/proxsync.egg-info/top_level.txt +0 -0
  14. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/__init__.py +0 -0
  15. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/api/__init__.py +0 -0
  16. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/api/serializers.py +0 -0
  17. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/api/urls.py +0 -0
  18. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/api/views.py +0 -0
  19. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/apps.py +0 -0
  20. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/choices.py +0 -0
  21. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/filtersets.py +0 -0
  22. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/forms.py +0 -0
  23. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/management/__init__.py +0 -0
  24. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/management/commands/__init__.py +0 -0
  25. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/management/commands/pve_sync.py +0 -0
  26. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/management/commands/run_scheduled_syncs.py +0 -0
  27. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0001_initial.py +0 -0
  28. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0002_pvepluginsettings.py +0 -0
  29. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +0 -0
  30. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0004_model_verbose_names.py +0 -0
  31. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +0 -0
  32. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +0 -0
  33. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +0 -0
  34. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +0 -0
  35. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +0 -0
  36. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0010_pvedriftevent.py +0 -0
  37. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +0 -0
  38. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +0 -0
  39. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +0 -0
  40. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0014_vmprovisioninglog.py +0 -0
  41. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0015_pvevmtasklog.py +0 -0
  42. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/0016_pvevmtasklog_status_textfield.py +0 -0
  43. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/migrations/__init__.py +0 -0
  44. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/models.py +0 -0
  45. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/navigation.py +0 -0
  46. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/signals.py +0 -0
  47. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/sync/__init__.py +0 -0
  48. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/sync/config_bridge.py +0 -0
  49. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/sync/engine.py +0 -0
  50. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/tables.py +0 -0
  51. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/tasks.py +0 -0
  52. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/template_content.py +0 -0
  53. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/dashboard.html +0 -0
  54. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +0 -0
  55. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/inc/vm_task_log_panel.html +0 -0
  56. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +0 -0
  57. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +0 -0
  58. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +0 -0
  59. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/pvedriftevent.html +0 -0
  60. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/pvesyncjob.html +0 -0
  61. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/pvevmtasklog_list.html +0 -0
  62. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +0 -0
  63. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/settings.html +0 -0
  64. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/vm_provisioning_combined.html +0 -0
  65. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templates/pve_sync/vm_provisioning_log.html +0 -0
  66. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templatetags/__init__.py +0 -0
  67. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/templatetags/pve_sync_tags.py +0 -0
  68. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/urls.py +0 -0
  69. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/utils.py +0 -0
  70. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/view_helpers.py +0 -0
  71. {proxsync-2.0.4 → proxsync-2.0.6}/pve_sync_plugin/views.py +0 -0
  72. {proxsync-2.0.4 → proxsync-2.0.6}/setup.cfg +0 -0
  73. {proxsync-2.0.4 → proxsync-2.0.6}/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.4
3
+ Version: 2.0.6
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.4
3
+ Version: 2.0.6
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}")
@@ -1084,6 +1099,11 @@ class OptimizedPVEToNetBoxSync:
1084
1099
  disks[key] = m.group(1) if m else val_str[:80]
1085
1100
  return disks
1086
1101
 
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
+
1087
1107
  def _write_drift_event(self, vm_name: str, vmid: int, drift_type: str,
1088
1108
  field_name: str, old_value: str, new_value: str,
1089
1109
  notified: bool = False):
@@ -1106,13 +1126,13 @@ class OptimizedPVEToNetBoxSync:
1106
1126
  except Exception as e:
1107
1127
  print(f" ⚠ 無法寫入漂移事件記錄: {e}")
1108
1128
 
1109
- 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):
1110
1130
  """若此 vmid 在先前同步中不存在(但叢集有先前記錄),視為新增 VM。"""
1111
1131
  if not self.enhanced_mode or not self.state_db:
1112
1132
  return
1113
1133
  if not self._known_vmids:
1114
1134
  return # 首次同步,無先前記錄,不觸發
1115
- if self.state_db.get_last_vm_config(vm_id, self.cluster_name):
1135
+ if last_config:
1116
1136
  return # 已有記錄,不是新增
1117
1137
  print(f"🆕 VM {vm_name} 新增偵測 (ID: {vm_id}, 節點: {node_name})")
1118
1138
  self.stats['config_drifts_detected'] += 1
@@ -1251,15 +1271,29 @@ class OptimizedPVEToNetBoxSync:
1251
1271
  print(f" 🧹 Log 清理:刪除 {d_del} 筆漂移事件、{t_del} 筆操作記錄(>{days} 天)")
1252
1272
  except Exception as exc:
1253
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}")
1254
1285
 
1255
1286
  def sync_vm_tasks(self):
1256
1287
  """從 PVE 各節點抓取 VM 操作記錄(建立/刪除/Clone/遷移)並同步到 NetBox。"""
1288
+ import calendar
1289
+ import datetime as _dt
1257
1290
  try:
1258
1291
  from django.utils import timezone
1259
1292
  from pve_sync_plugin.models import PveVmTaskLog
1260
1293
  except ImportError:
1261
1294
  return
1262
1295
 
1296
+ _utc = _dt.timezone.utc
1263
1297
  VM_TASK_TYPES = {
1264
1298
  "qmcreate", "qmdestroy", "qmclone", "qmmigrate", "qmrestore",
1265
1299
  "qmstart", "qmstop", "qmshutdown", "qmreboot",
@@ -1293,10 +1327,18 @@ class OptimizedPVEToNetBoxSync:
1293
1327
  cluster_name=self.cluster_name
1294
1328
  ).order_by("-start_time").values_list("start_time", flat=True).first()
1295
1329
  if latest:
1296
- import calendar
1297
1330
  since_ts = int(calendar.timegm(latest.utctimetuple())) + 1
1298
1331
  else:
1299
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)
1300
1342
  new_count = 0
1301
1343
 
1302
1344
  for node_name in self.pve_cache.get("vms_by_node", {}):
@@ -1320,8 +1362,8 @@ class OptimizedPVEToNetBoxSync:
1320
1362
  except (ValueError, TypeError):
1321
1363
  continue
1322
1364
 
1323
- # UPID 去重
1324
- if PveVmTaskLog.objects.filter(upid=upid).exists():
1365
+ # UPID 去重(使用預先撈取的集合,避免 N+1 查詢)
1366
+ if upid in existing_upids or upid in new_upids:
1325
1367
  continue
1326
1368
 
1327
1369
  task_type = task.get("type")
@@ -1331,8 +1373,6 @@ class OptimizedPVEToNetBoxSync:
1331
1373
  status = task.get("status", "") or ""
1332
1374
  vm_name = vmid_to_name.get(vmid, f"VM-{vmid}")
1333
1375
 
1334
- import datetime as _dt
1335
- _utc = _dt.timezone.utc
1336
1376
  start_dt = _dt.datetime.fromtimestamp(start_ts, tz=_utc) if start_ts else timezone.now()
1337
1377
  end_dt = _dt.datetime.fromtimestamp(end_ts, tz=_utc) if end_ts else None
1338
1378
 
@@ -1349,6 +1389,7 @@ class OptimizedPVEToNetBoxSync:
1349
1389
  status=status,
1350
1390
  notified_telegram=False,
1351
1391
  )
1392
+ new_upids.add(upid)
1352
1393
  new_count += 1
1353
1394
 
1354
1395
  # Telegram 通知(僅近期且已完成的操作)
@@ -1376,11 +1417,12 @@ class OptimizedPVEToNetBoxSync:
1376
1417
 
1377
1418
  def detect_config_drift(self, vm_id: int, vm_name: str, vm_config: Dict[str, Any],
1378
1419
  current_tags: List[str], network_interfaces: List[Dict] = None,
1379
- current_node: str = None) -> List[Dict]:
1420
+ current_node: str = None, last_config=None) -> List[Dict]:
1380
1421
  if not self.enhanced_mode or not self.state_db:
1381
1422
  return []
1382
1423
  drifts = []
1383
- 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)
1384
1426
  if not last_config:
1385
1427
  return drifts
1386
1428
  current_memory = int(vm_config.get('memory', 0))
@@ -1461,8 +1503,8 @@ class OptimizedPVEToNetBoxSync:
1461
1503
  self._write_drift_event(vm_name, vm_id, 'disk_change', k, old_v, new_v, notified=True)
1462
1504
 
1463
1505
  # VM 描述變更偵測
1464
- current_desc = (vm_config.get('description') or '').strip()[:500]
1465
- old_desc = (last_config.get('description') or '').strip()
1506
+ current_desc = self._norm_desc(vm_config.get('description'))[:500]
1507
+ old_desc = self._norm_desc(last_config.get('description'))
1466
1508
  if old_desc and old_desc != current_desc:
1467
1509
  self.stats['config_drifts_detected'] += 1
1468
1510
  print(f"📝 VM {vm_name} 描述變更")
@@ -1502,7 +1544,7 @@ class OptimizedPVEToNetBoxSync:
1502
1544
  return drifts
1503
1545
 
1504
1546
  def should_sync_vm(self, vm_id: int, vm_config: Dict[str, Any], tags: List[str],
1505
- network_interfaces: List[Dict] = None) -> bool:
1547
+ network_interfaces: List[Dict] = None, last_config=None) -> bool:
1506
1548
  if not self.sync_config.get('incremental', True):
1507
1549
  return True
1508
1550
  if self.sync_config.get('force_full_sync', False):
@@ -1510,34 +1552,35 @@ class OptimizedPVEToNetBoxSync:
1510
1552
  current_hash = compute_config_hash(vm_config, tags, network_interfaces)
1511
1553
  if not self.state_db:
1512
1554
  return True
1513
- 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)
1514
1557
  if not last_config:
1515
1558
  return True
1516
1559
  if current_hash != last_config.get('config_hash'):
1517
1560
  return True
1518
1561
  return False
1519
1562
 
1520
- 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]:
1521
1565
  if not self.enhanced_mode or not self.state_db:
1522
1566
  return []
1523
- changes = []
1524
- 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)
1525
1569
  if not last_config:
1526
- return changes
1570
+ return []
1527
1571
  old_tags = set(last_config.get('tags', []))
1528
1572
  new_tags = set(current_tags)
1529
1573
  added = new_tags - old_tags
1530
1574
  removed = old_tags - new_tags
1575
+ changes = []
1531
1576
  for tag in added:
1532
1577
  changes.append({'type': 'added', 'tag': tag})
1533
1578
  self.stats['tag_changes'] += 1
1534
1579
  print(f"🏷️ VM {vm_name} 新增標籤: {tag}")
1535
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', '', tag, notified=False)
1536
1580
  for tag in removed:
1537
1581
  changes.append({'type': 'removed', 'tag': tag})
1538
1582
  self.stats['tag_changes'] += 1
1539
1583
  print(f"🏷️ VM {vm_name} 移除標籤: {tag}")
1540
- self._write_drift_event(vm_name, vm_id, 'tag_change', 'tags', tag, '', notified=False)
1541
1584
  if changes:
1542
1585
  added_list = [c['tag'] for c in changes if c['type'] == 'added']
1543
1586
  removed_list = [c['tag'] for c in changes if c['type'] == 'removed']
@@ -1553,7 +1596,12 @@ class OptimizedPVEToNetBoxSync:
1553
1596
  f"📅 時間: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1554
1597
  f"{detail}\n請確認是否為預期變更。"
1555
1598
  )
1556
- 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)
1557
1605
  return changes
1558
1606
 
1559
1607
  def detect_ip_change(self, vm_id: int, vm_name: str, current_ip: str):
@@ -1621,10 +1669,16 @@ class OptimizedPVEToNetBoxSync:
1621
1669
  tag_ids.append(tag.id)
1622
1670
  except Exception as e:
1623
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
+ )
1624
1678
  if self.enhanced_mode:
1625
- 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)
1626
1680
  if self.enhanced_mode and tag_names:
1627
- 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)
1628
1682
  vm_status = vm_data.get('status', '')
1629
1683
  if tag_names and vm_status in ('running', 'stopped'):
1630
1684
  first_tag = tag_names[0].lower()
@@ -1707,8 +1761,9 @@ class OptimizedPVEToNetBoxSync:
1707
1761
  })
1708
1762
  if self.enhanced_mode:
1709
1763
  self.detect_config_drift(int(vm_id), original_vm_name, vm_config, tag_names,
1710
- network_interfaces, current_node=node_name)
1711
- 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):
1712
1767
  cached_vm = self.nb_cache['virtual_machines_by_serial'].get(f"{vm_id}::{cluster['id']}")
1713
1768
  if cached_vm is None:
1714
1769
  # VM 在 NetBox 中不存在(例如隨節點被連帶刪除),
@@ -1749,7 +1804,7 @@ class OptimizedPVEToNetBoxSync:
1749
1804
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1750
1805
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1751
1806
  node=node_name, primary_ip=cur_ip, vm_name=original_vm_name,
1752
- description=(vm_config.get('description') or '').strip()[:500],
1807
+ description=self._norm_desc(vm_config.get('description'))[:500],
1753
1808
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1754
1809
  )
1755
1810
  except Exception as _snap_err:
@@ -1806,7 +1861,7 @@ class OptimizedPVEToNetBoxSync:
1806
1861
  update_data = {
1807
1862
  'name': vm_name, 'cluster': cluster['id'], 'device': device.id, 'role': role_id,
1808
1863
  'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)), 'status': status,
1809
- 'description': (vm_config.get('description') or '')[:200], 'platform': platform_id,
1864
+ 'description': self._norm_desc(vm_config.get('description'))[:200], 'platform': platform_id,
1810
1865
  'start_on_boot': boot_choice
1811
1866
  }
1812
1867
  if tag_ids:
@@ -1825,7 +1880,7 @@ class OptimizedPVEToNetBoxSync:
1825
1880
  vm_data_dict = {
1826
1881
  'serial': vm_id, 'name': vm_name, 'cluster': cluster['id'], 'device': device.id,
1827
1882
  'role': role_id, 'vcpus': vcpus, 'memory': int(vm_config.get('memory', 0)),
1828
- 'status': status, 'description': (vm_config.get('description') or '')[:200],
1883
+ 'status': status, 'description': self._norm_desc(vm_config.get('description'))[:200],
1829
1884
  'platform': platform_id, 'start_on_boot': boot_choice
1830
1885
  }
1831
1886
  if tag_ids:
@@ -1862,7 +1917,7 @@ class OptimizedPVEToNetBoxSync:
1862
1917
  vm_id=int(vm_id), cluster_name=self.cluster_name, config_hash=config_hash,
1863
1918
  memory=memory, vcpus=vcpus_save, tags=tag_names,
1864
1919
  node=node_name, primary_ip=primary_ip_str, vm_name=original_vm_name,
1865
- description=(vm_config.get('description') or '').strip()[:500],
1920
+ description=self._norm_desc(vm_config.get('description'))[:500],
1866
1921
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1867
1922
  )
1868
1923
  return True
@@ -1887,7 +1942,7 @@ class OptimizedPVEToNetBoxSync:
1887
1942
  node=node_name,
1888
1943
  primary_ip=_ip,
1889
1944
  vm_name=original_vm_name,
1890
- description=(vm_config.get('description') or '').strip()[:500],
1945
+ description=self._norm_desc(vm_config.get('description'))[:500],
1891
1946
  disk_summary=json.dumps(self._parse_disk_summary(vm_config)),
1892
1947
  )
1893
1948
  except Exception:
@@ -1930,6 +1985,18 @@ class OptimizedPVEToNetBoxSync:
1930
1985
  print("=" * 50)
1931
1986
  if self.enhanced_mode and self.state_db:
1932
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
1933
2000
  sync_mode = "增量" if self.sync_config.get('incremental', True) else "全量"
1934
2001
  start_message = f"""
1935
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.4"
7
+ version = "2.0.6"
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],
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