proxsync 2.0.0__py3-none-any.whl

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 (59) hide show
  1. config.py +337 -0
  2. pbs_sync.py +244 -0
  3. proxsync-2.0.0.dist-info/METADATA +468 -0
  4. proxsync-2.0.0.dist-info/RECORD +59 -0
  5. proxsync-2.0.0.dist-info/WHEEL +5 -0
  6. proxsync-2.0.0.dist-info/top_level.txt +5 -0
  7. pve_sync.py +1804 -0
  8. pve_sync_plugin/__init__.py +46 -0
  9. pve_sync_plugin/api/__init__.py +1 -0
  10. pve_sync_plugin/api/serializers.py +161 -0
  11. pve_sync_plugin/api/urls.py +27 -0
  12. pve_sync_plugin/api/views.py +94 -0
  13. pve_sync_plugin/apps.py +5 -0
  14. pve_sync_plugin/choices.py +57 -0
  15. pve_sync_plugin/filtersets.py +137 -0
  16. pve_sync_plugin/forms.py +219 -0
  17. pve_sync_plugin/management/__init__.py +1 -0
  18. pve_sync_plugin/management/commands/__init__.py +1 -0
  19. pve_sync_plugin/management/commands/pve_sync.py +182 -0
  20. pve_sync_plugin/management/commands/run_scheduled_syncs.py +109 -0
  21. pve_sync_plugin/migrations/0001_initial.py +234 -0
  22. pve_sync_plugin/migrations/0002_pvepluginsettings.py +42 -0
  23. pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +148 -0
  24. pve_sync_plugin/migrations/0004_model_verbose_names.py +34 -0
  25. pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +111 -0
  26. pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +124 -0
  27. pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +26 -0
  28. pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +26 -0
  29. pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +39 -0
  30. pve_sync_plugin/migrations/0010_pvedriftevent.py +62 -0
  31. pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +19 -0
  32. pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +39 -0
  33. pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +19 -0
  34. pve_sync_plugin/migrations/__init__.py +1 -0
  35. pve_sync_plugin/models.py +461 -0
  36. pve_sync_plugin/navigation.py +94 -0
  37. pve_sync_plugin/signals.py +24 -0
  38. pve_sync_plugin/sync/__init__.py +8 -0
  39. pve_sync_plugin/sync/config_bridge.py +221 -0
  40. pve_sync_plugin/sync/engine.py +50 -0
  41. pve_sync_plugin/tables.py +278 -0
  42. pve_sync_plugin/tasks.py +440 -0
  43. pve_sync_plugin/template_content.py +44 -0
  44. pve_sync_plugin/templates/pve_sync/dashboard.html +400 -0
  45. pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +7 -0
  46. pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +69 -0
  47. pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +27 -0
  48. pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +52 -0
  49. pve_sync_plugin/templates/pve_sync/pvedriftevent.html +89 -0
  50. pve_sync_plugin/templates/pve_sync/pvesyncjob.html +197 -0
  51. pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +42 -0
  52. pve_sync_plugin/templates/pve_sync/settings.html +79 -0
  53. pve_sync_plugin/templatetags/__init__.py +1 -0
  54. pve_sync_plugin/templatetags/pve_sync_tags.py +86 -0
  55. pve_sync_plugin/urls.py +186 -0
  56. pve_sync_plugin/utils.py +84 -0
  57. pve_sync_plugin/view_helpers.py +98 -0
  58. pve_sync_plugin/views.py +535 -0
  59. state_db.py +543 -0
@@ -0,0 +1,461 @@
1
+ import datetime
2
+
3
+ from django.db import models
4
+ from django.urls import reverse
5
+ from django.utils import timezone
6
+
7
+ from netbox.models import NetBoxModel
8
+
9
+ from .choices import (
10
+ BackupStatusChoices,
11
+ DriftTypeChoices,
12
+ SyncJobStatusChoices,
13
+ SyncJobTriggerChoices,
14
+ SyncScheduleChoices,
15
+ WebhookEventChoices,
16
+ )
17
+
18
+
19
+ class PveSyncJob(NetBoxModel):
20
+ cluster_name = models.CharField(
21
+ max_length=100,
22
+ default="default",
23
+ help_text="同步的集群名称",
24
+ )
25
+ status = models.CharField(
26
+ max_length=20,
27
+ choices=SyncJobStatusChoices,
28
+ default=SyncJobStatusChoices.STATUS_PENDING,
29
+ )
30
+ start_time = models.DateTimeField(auto_now_add=True)
31
+ end_time = models.DateTimeField(null=True, blank=True)
32
+
33
+ total_vms = models.IntegerField(default=0)
34
+ success_vms = models.IntegerField(default=0)
35
+ failed_vms = models.IntegerField(default=0)
36
+
37
+ nodes_offline = models.IntegerField(default=0)
38
+ config_drifts = models.IntegerField(default=0)
39
+ tag_changes = models.IntegerField(default=0)
40
+ resource_alerts = models.IntegerField(default=0)
41
+
42
+ details = models.JSONField(default=dict, blank=True)
43
+
44
+ trigger = models.CharField(
45
+ max_length=20,
46
+ choices=SyncJobTriggerChoices,
47
+ default=SyncJobTriggerChoices.TRIGGER_MANUAL,
48
+ )
49
+
50
+ triggered_by = models.ForeignKey(
51
+ "users.User",
52
+ on_delete=models.SET_NULL,
53
+ null=True,
54
+ blank=True,
55
+ related_name="pve_sync_jobs",
56
+ )
57
+
58
+ class Meta:
59
+ ordering = ["-start_time"]
60
+ indexes = [
61
+ models.Index(fields=["status", "start_time"]),
62
+ models.Index(fields=["cluster_name"]),
63
+ ]
64
+ verbose_name = "Proxmox Sync Job"
65
+ verbose_name_plural = "Proxmox Sync Jobs"
66
+
67
+ def __str__(self):
68
+ return f"{self.cluster_name} - {self.get_status_display()} ({self.start_time})"
69
+
70
+ def get_absolute_url(self):
71
+ return reverse("plugins:pve_sync_plugin:pvesyncjob", args=[self.pk])
72
+
73
+ @property
74
+ def duration(self):
75
+ if self.end_time and self.start_time:
76
+ return (self.end_time - self.start_time).total_seconds()
77
+ return None
78
+
79
+ @property
80
+ def success_rate(self):
81
+ if self.total_vms > 0:
82
+ return (self.success_vms / self.total_vms) * 100
83
+ return 0
84
+
85
+ @property
86
+ def status_badge(self):
87
+ """Return CSS class for status badges in templates."""
88
+ return {
89
+ "pending": "info",
90
+ "running": "primary",
91
+ "success": "success",
92
+ "failed": "danger",
93
+ "partial": "warning",
94
+ }.get(self.status, "secondary")
95
+
96
+ @property
97
+ def error_message(self):
98
+ if not self.details:
99
+ return ""
100
+ return self.details.get("error") or self.details.get("queue_error") or ""
101
+
102
+
103
+ class PveWebhookEvent(NetBoxModel):
104
+ event_type = models.CharField(max_length=50, choices=WebhookEventChoices)
105
+ node = models.CharField(max_length=100, null=True, blank=True)
106
+ vmid = models.IntegerField(null=True, blank=True)
107
+ vm_name = models.CharField(max_length=200, null=True, blank=True)
108
+
109
+ raw_data = models.JSONField(default=dict)
110
+
111
+ processed = models.BooleanField(default=False)
112
+ processed_at = models.DateTimeField(null=True, blank=True)
113
+ processing_error = models.TextField(blank=True)
114
+
115
+ sync_job = models.ForeignKey(
116
+ "PveSyncJob",
117
+ on_delete=models.SET_NULL,
118
+ null=True,
119
+ blank=True,
120
+ related_name="webhook_events",
121
+ )
122
+
123
+ received_at = models.DateTimeField(auto_now_add=True)
124
+
125
+ class Meta:
126
+ ordering = ["-received_at"]
127
+ indexes = [
128
+ models.Index(fields=["event_type", "processed"]),
129
+ models.Index(fields=["vmid"]),
130
+ models.Index(fields=["received_at"]),
131
+ ]
132
+ verbose_name = "PVE Webhook Event"
133
+ verbose_name_plural = "PVE Webhook Events"
134
+
135
+ def __str__(self):
136
+ return f"{self.event_type} - {self.vm_name or self.node} ({self.received_at})"
137
+
138
+ def get_absolute_url(self):
139
+ return reverse("plugins:pve_sync_plugin:pvewebhookevent", args=[self.pk])
140
+
141
+ def mark_processed(self, sync_job=None, error=None):
142
+ self.processed = True
143
+ self.processed_at = timezone.now()
144
+ if sync_job:
145
+ self.sync_job = sync_job
146
+ if error:
147
+ self.processing_error = error
148
+ self.save()
149
+
150
+
151
+ class PveBackupStatus(NetBoxModel):
152
+ vm = models.OneToOneField(
153
+ "virtualization.VirtualMachine",
154
+ on_delete=models.CASCADE,
155
+ related_name="pve_backup_status",
156
+ )
157
+
158
+ last_backup = models.DateTimeField(null=True, blank=True)
159
+ backup_size = models.BigIntegerField(null=True, blank=True, help_text="字节")
160
+ backup_status = models.CharField(
161
+ max_length=20,
162
+ choices=BackupStatusChoices,
163
+ default=BackupStatusChoices.STATUS_UNKNOWN,
164
+ )
165
+ backup_path = models.CharField(max_length=500, blank=True)
166
+ next_backup = models.DateTimeField(null=True, blank=True)
167
+
168
+ pve_backup_id = models.CharField(max_length=100, unique=True, null=True, blank=True)
169
+
170
+ class Meta:
171
+ verbose_name = "PVE Backup Status"
172
+ verbose_name_plural = "PVE Backup Statuses"
173
+
174
+ def __str__(self):
175
+ return f"{self.vm.name} - {self.backup_status}"
176
+
177
+ def get_absolute_url(self):
178
+ return reverse("plugins:pve_sync_plugin:pvebackupstatus", args=[self.pk])
179
+
180
+ @property
181
+ def backup_age_days(self):
182
+ if self.last_backup:
183
+ delta = timezone.now() - self.last_backup
184
+ return delta.days
185
+ return None
186
+
187
+ @property
188
+ def is_stale(self):
189
+ if not self.last_backup:
190
+ return True
191
+ return self.backup_age_days > 7
192
+
193
+
194
+ class PveDriftEvent(NetBoxModel):
195
+ """Records a single configuration drift event detected during sync."""
196
+
197
+ vm_name = models.CharField(max_length=200)
198
+ vmid = models.IntegerField()
199
+ cluster_name = models.CharField(max_length=100)
200
+ drift_type = models.CharField(max_length=30, choices=DriftTypeChoices)
201
+ field_name = models.CharField(max_length=100, verbose_name="欄位")
202
+ old_value = models.TextField(blank=True, verbose_name="舊值")
203
+ new_value = models.TextField(blank=True, verbose_name="新值")
204
+ sync_job = models.ForeignKey(
205
+ "PveSyncJob",
206
+ on_delete=models.SET_NULL,
207
+ null=True,
208
+ blank=True,
209
+ related_name="drift_events",
210
+ )
211
+ notified_telegram = models.BooleanField(default=False)
212
+
213
+ class Meta:
214
+ ordering = ["-created"]
215
+ indexes = [
216
+ models.Index(fields=["vmid", "cluster_name"]),
217
+ models.Index(fields=["drift_type"]),
218
+ models.Index(fields=["created"]),
219
+ ]
220
+ verbose_name = "VM Drift Event"
221
+ verbose_name_plural = "VM Drift Events"
222
+
223
+ def __str__(self):
224
+ return f"{self.vm_name} [{self.get_drift_type_display()}] {self.field_name}: {self.old_value} → {self.new_value}"
225
+
226
+ def get_absolute_url(self):
227
+ return reverse("plugins:pve_sync_plugin:pvedriftevent", args=[self.pk])
228
+
229
+ @property
230
+ def drift_type_badge(self):
231
+ return {
232
+ "hardware": "warning",
233
+ "migration": "info",
234
+ "ip_change": "primary",
235
+ "tag_change": "secondary",
236
+ "vm_created": "success",
237
+ "vm_deleted": "danger",
238
+ "vm_renamed": "info",
239
+ "disk_change": "warning",
240
+ "description_change": "secondary",
241
+ }.get(self.drift_type, "secondary")
242
+
243
+
244
+ class PvePluginSettings(NetBoxModel):
245
+ """Singleton settings editable from the NetBox Web UI."""
246
+
247
+ netbox_url = models.URLField(blank=True)
248
+ netbox_token = models.CharField(max_length=200, blank=True)
249
+
250
+ telegram_bot_token = models.CharField(max_length=200, blank=True)
251
+ telegram_chat_id = models.CharField(max_length=100, blank=True)
252
+ webhook_secret = models.CharField(max_length=200, blank=True)
253
+
254
+ default_cluster_name = models.CharField(max_length=100, default="default")
255
+ default_netbox_cluster = models.CharField(max_length=100, default="Proxmox Cluster")
256
+ default_site = models.CharField(max_length=100, default="Main Datacenter")
257
+ default_cluster_type = models.CharField(max_length=100, default="Proxmox")
258
+ default_node_role = models.CharField(max_length=100, default="PVE")
259
+ default_node_type = models.CharField(max_length=100, default="Standard Server")
260
+
261
+ state_db_path = models.CharField(
262
+ max_length=500,
263
+ default="/var/lib/netbox/pve-sync-state.db",
264
+ )
265
+ enable_backup_sync = models.BooleanField(default=True)
266
+ log_retention_days = models.PositiveIntegerField(
267
+ default=90,
268
+ help_text="保留同步 Log 及 State DB 歷史資料天數(0 = 不自動清除)",
269
+ )
270
+
271
+ class Meta:
272
+ verbose_name = "Proxmox Sync Settings"
273
+ verbose_name_plural = "Proxmox Sync Settings"
274
+
275
+ def __str__(self):
276
+ return "Proxmox Sync Settings"
277
+
278
+ def get_absolute_url(self):
279
+ return reverse("plugins:pve_sync_plugin:settings")
280
+
281
+ def save(self, *args, **kwargs):
282
+ self.pk = 1
283
+ return super().save(*args, **kwargs)
284
+
285
+ def delete(self, *args, **kwargs):
286
+ """Prevent deletion of the singleton settings row."""
287
+ pass
288
+
289
+ @classmethod
290
+ def load(cls):
291
+ settings, _ = cls.objects.get_or_create(pk=1)
292
+ return settings
293
+
294
+
295
+ class PveClusterConfig(NetBoxModel):
296
+ name = models.CharField(max_length=100, unique=True)
297
+ description = models.TextField(blank=True)
298
+
299
+ pve_host = models.CharField(max_length=200)
300
+ pve_user = models.CharField(max_length=100)
301
+ pve_token = models.CharField(max_length=100)
302
+ pve_secret = models.CharField(max_length=200)
303
+ pve_verify_ssl = models.BooleanField(default=False)
304
+
305
+ netbox_site = models.ForeignKey(
306
+ "dcim.Site",
307
+ on_delete=models.SET_NULL,
308
+ null=True,
309
+ blank=True,
310
+ related_name="pve_clusters",
311
+ )
312
+ netbox_cluster_type = models.ForeignKey(
313
+ "virtualization.ClusterType",
314
+ on_delete=models.SET_NULL,
315
+ null=True,
316
+ blank=True,
317
+ )
318
+ netbox_cluster = models.ForeignKey(
319
+ "virtualization.Cluster",
320
+ on_delete=models.SET_NULL,
321
+ null=True,
322
+ blank=True,
323
+ related_name="pve_sync_config",
324
+ )
325
+
326
+ enabled = models.BooleanField(default=True)
327
+ sync_schedule = models.CharField(
328
+ max_length=20,
329
+ choices=SyncScheduleChoices,
330
+ default=SyncScheduleChoices.DISABLED,
331
+ )
332
+ notify_on_sync = models.BooleanField(
333
+ default=True,
334
+ help_text="同步時是否發送 Telegram 通知(含同步開始/完成、漂移偵測等)",
335
+ )
336
+
337
+ last_sync = models.DateTimeField(null=True, blank=True)
338
+ last_sync_status = models.CharField(max_length=20, blank=True)
339
+
340
+ class Meta:
341
+ ordering = ["name"]
342
+ verbose_name = "PVE Cluster Config"
343
+ verbose_name_plural = "PVE Cluster Configs"
344
+
345
+ def __str__(self):
346
+ return self.name
347
+
348
+ def save(self, *args, **kwargs):
349
+ # Cascade name changes so that PveSyncJob records and plugin settings
350
+ # stay consistent, and state_db incremental history is preserved.
351
+ old_name = None
352
+ if self.pk:
353
+ try:
354
+ old_name = (
355
+ PveClusterConfig.objects.filter(pk=self.pk)
356
+ .values_list("name", flat=True)
357
+ .first()
358
+ )
359
+ except Exception:
360
+ pass
361
+
362
+ super().save(*args, **kwargs)
363
+
364
+ if old_name and old_name != self.name:
365
+ PveSyncJob.objects.filter(cluster_name=old_name).update(cluster_name=self.name)
366
+ PvePluginSettings.objects.filter(default_cluster_name=old_name).update(
367
+ default_cluster_name=self.name
368
+ )
369
+ # Rename cluster in state_db so incremental sync stays effective.
370
+ try:
371
+ from state_db import StateDB
372
+ db_path = PvePluginSettings.load().state_db_path or "/var/lib/pve-sync/state.db"
373
+ StateDB(db_path).rename_cluster(old_name, self.name)
374
+ except Exception:
375
+ pass # state_db rename is best-effort; full sync will happen if it fails
376
+
377
+ def get_sync_schedule_color(self):
378
+ return {
379
+ 'hourly': 'info',
380
+ 'every_3h': 'info',
381
+ 'every_6h': 'info',
382
+ 'daily': 'secondary',
383
+ 'weekly': 'secondary',
384
+ }.get(self.sync_schedule, 'secondary')
385
+
386
+ def get_absolute_url(self):
387
+ return reverse("plugins:pve_sync_plugin:pveclusterconfig", args=[self.pk])
388
+
389
+ @property
390
+ def is_active(self):
391
+ if not self.enabled:
392
+ return False
393
+ if not self.last_sync:
394
+ return False
395
+ recent = timezone.now() - datetime.timedelta(hours=24)
396
+ return self.last_sync > recent and self.last_sync_status == "success"
397
+
398
+
399
+ class PbsServerConfig(NetBoxModel):
400
+ """Proxmox Backup Server connection configuration."""
401
+
402
+ name = models.CharField(max_length=100, unique=True)
403
+ description = models.TextField(blank=True)
404
+ pbs_host = models.CharField(
405
+ max_length=200,
406
+ help_text="PBS API URL (e.g. https://pbs01:8007)",
407
+ )
408
+ pbs_token_name = models.CharField(
409
+ max_length=100,
410
+ help_text="API token (e.g. root@pam!apitoken)",
411
+ )
412
+ pbs_token_secret = models.CharField(max_length=200)
413
+ pbs_verify_ssl = models.BooleanField(default=False)
414
+ pbs_node_name = models.CharField(
415
+ max_length=100,
416
+ help_text="PBS node name",
417
+ )
418
+ netbox_site = models.ForeignKey(
419
+ "dcim.Site",
420
+ on_delete=models.SET_NULL,
421
+ null=True,
422
+ blank=True,
423
+ related_name="pbs_servers",
424
+ )
425
+ enabled = models.BooleanField(default=True)
426
+ sync_schedule = models.CharField(
427
+ max_length=20,
428
+ choices=SyncScheduleChoices,
429
+ default=SyncScheduleChoices.DISABLED,
430
+ )
431
+ last_sync = models.DateTimeField(null=True, blank=True)
432
+ last_sync_status = models.CharField(max_length=20, blank=True)
433
+
434
+ class Meta:
435
+ ordering = ["name"]
436
+ verbose_name = "PBS Server Config"
437
+ verbose_name_plural = "PBS Server Configs"
438
+
439
+ def __str__(self):
440
+ return self.name
441
+
442
+ def get_sync_schedule_color(self):
443
+ return {
444
+ 'hourly': 'info',
445
+ 'every_3h': 'info',
446
+ 'every_6h': 'info',
447
+ 'daily': 'secondary',
448
+ 'weekly': 'secondary',
449
+ }.get(self.sync_schedule, 'secondary')
450
+
451
+ def get_absolute_url(self):
452
+ return reverse("plugins:pve_sync_plugin:pbsserverconfig", args=[self.pk])
453
+
454
+ @property
455
+ def display_host(self):
456
+ """Return just the hostname/IP without scheme or port."""
457
+ host = self.pbs_host
458
+ if '://' in host:
459
+ host = host.split('://', 1)[1]
460
+ host = host.split(':')[0]
461
+ return host
@@ -0,0 +1,94 @@
1
+ """Navigation items exposed under NetBox's Plugins menu."""
2
+
3
+ from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
4
+
5
+ from netbox.choices import ButtonColorChoices
6
+
7
+
8
+ dashboard_item = PluginMenuItem(
9
+ link="plugins:pve_sync_plugin:dashboard",
10
+ link_text="Dashboard",
11
+ buttons=(
12
+ PluginMenuButton(
13
+ "plugins:pve_sync_plugin:trigger-sync",
14
+ "Run sync",
15
+ "mdi mdi-sync",
16
+ ButtonColorChoices.GREEN,
17
+ ),
18
+ ),
19
+ )
20
+
21
+ jobs_item = PluginMenuItem(
22
+ link="plugins:pve_sync_plugin:pvesyncjob_list",
23
+ link_text="Sync Jobs",
24
+ )
25
+
26
+ events_item = PluginMenuItem(
27
+ link="plugins:pve_sync_plugin:pvewebhookevent_list",
28
+ link_text="Webhook Events",
29
+ )
30
+
31
+ drift_events_item = PluginMenuItem(
32
+ link="plugins:pve_sync_plugin:pvedriftevent_list",
33
+ link_text="Drift Events",
34
+ )
35
+
36
+ clusters_item = PluginMenuItem(
37
+ link="plugins:pve_sync_plugin:pveclusterconfig_list",
38
+ link_text="PVE Clusters",
39
+ buttons=(
40
+ PluginMenuButton(
41
+ "plugins:pve_sync_plugin:pveclusterconfig_add",
42
+ "Add cluster",
43
+ "mdi mdi-plus-thick",
44
+ ButtonColorChoices.GREEN,
45
+ ),
46
+ ),
47
+ )
48
+
49
+ backup_status_item = PluginMenuItem(
50
+ link="plugins:pve_sync_plugin:pvebackupstatus_list",
51
+ link_text="Backup Status",
52
+ )
53
+
54
+ settings_item = PluginMenuItem(
55
+ link="plugins:pve_sync_plugin:settings",
56
+ link_text="Settings",
57
+ )
58
+
59
+ pbs_item = PluginMenuItem(
60
+ link="plugins:pve_sync_plugin:pbsserverconfig_list",
61
+ link_text="PBS Servers",
62
+ buttons=(
63
+ PluginMenuButton(
64
+ "plugins:pve_sync_plugin:pbsserverconfig_add",
65
+ "Add",
66
+ "mdi mdi-plus-thick",
67
+ ButtonColorChoices.GREEN,
68
+ ),
69
+ ),
70
+ )
71
+
72
+
73
+ menu = PluginMenu(
74
+ label="Proxmox Sync",
75
+ groups=(
76
+ (
77
+ "Overview",
78
+ (dashboard_item,),
79
+ ),
80
+ (
81
+ "Sync & Operations",
82
+ (jobs_item, events_item, drift_events_item),
83
+ ),
84
+ (
85
+ "Data Protection",
86
+ (backup_status_item,),
87
+ ),
88
+ (
89
+ "Configuration",
90
+ (clusters_item, pbs_item, settings_item),
91
+ ),
92
+ ),
93
+ icon_class="mdi mdi-hexagon-multiple",
94
+ )
@@ -0,0 +1,24 @@
1
+ """
2
+ PVE Sync Plugin Signals
3
+ 在 VM 創建時自動建立備份狀態記錄
4
+ """
5
+
6
+ from django.apps import apps
7
+ from django.db.models.signals import post_save
8
+ from django.dispatch import receiver
9
+
10
+
11
+ def get_vm_model():
12
+ return apps.get_model('virtualization', 'VirtualMachine')
13
+
14
+
15
+ def _ensure_backup_status(sender, instance, created, **kwargs):
16
+ if not created:
17
+ return
18
+ from .models import PveBackupStatus
19
+ PveBackupStatus.objects.get_or_create(vm=instance)
20
+
21
+
22
+ def register_signals():
23
+ VirtualMachine = get_vm_model()
24
+ post_save.connect(_ensure_backup_status, sender=VirtualMachine, weak=False)
@@ -0,0 +1,8 @@
1
+ """PVE Sync engine subpackage.
2
+
3
+ Provides the core synchronization logic, config bridging,
4
+ and state management — usable from both the NetBox plugin
5
+ and the standalone ``python pve_sync.py`` CLI.
6
+ """
7
+
8
+ from .engine import PVESyncEngine # noqa: F401