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.
- config.py +337 -0
- pbs_sync.py +244 -0
- proxsync-2.0.0.dist-info/METADATA +468 -0
- proxsync-2.0.0.dist-info/RECORD +59 -0
- proxsync-2.0.0.dist-info/WHEEL +5 -0
- proxsync-2.0.0.dist-info/top_level.txt +5 -0
- pve_sync.py +1804 -0
- pve_sync_plugin/__init__.py +46 -0
- pve_sync_plugin/api/__init__.py +1 -0
- pve_sync_plugin/api/serializers.py +161 -0
- pve_sync_plugin/api/urls.py +27 -0
- pve_sync_plugin/api/views.py +94 -0
- pve_sync_plugin/apps.py +5 -0
- pve_sync_plugin/choices.py +57 -0
- pve_sync_plugin/filtersets.py +137 -0
- pve_sync_plugin/forms.py +219 -0
- pve_sync_plugin/management/__init__.py +1 -0
- pve_sync_plugin/management/commands/__init__.py +1 -0
- pve_sync_plugin/management/commands/pve_sync.py +182 -0
- pve_sync_plugin/management/commands/run_scheduled_syncs.py +109 -0
- pve_sync_plugin/migrations/0001_initial.py +234 -0
- pve_sync_plugin/migrations/0002_pvepluginsettings.py +42 -0
- pve_sync_plugin/migrations/0003_netboxmodel_timestamps.py +148 -0
- pve_sync_plugin/migrations/0004_model_verbose_names.py +34 -0
- pve_sync_plugin/migrations/0005_align_netboxmodel_timestamps.py +111 -0
- pve_sync_plugin/migrations/0006_pbsserverconfig_and_more.py +124 -0
- pve_sync_plugin/migrations/0007_rename_verbose_names_to_proxmox_sync.py +26 -0
- pve_sync_plugin/migrations/0008_pbsserverconfig_sync_schedule.py +26 -0
- pve_sync_plugin/migrations/0009_add_every_3h_schedule.py +39 -0
- pve_sync_plugin/migrations/0010_pvedriftevent.py +62 -0
- pve_sync_plugin/migrations/0011_pveclusterconfig_notify_on_sync.py +19 -0
- pve_sync_plugin/migrations/0012_pvedriftevent_tags_and_index_renames.py +39 -0
- pve_sync_plugin/migrations/0013_pluginsettings_log_retention.py +19 -0
- pve_sync_plugin/migrations/__init__.py +1 -0
- pve_sync_plugin/models.py +461 -0
- pve_sync_plugin/navigation.py +94 -0
- pve_sync_plugin/signals.py +24 -0
- pve_sync_plugin/sync/__init__.py +8 -0
- pve_sync_plugin/sync/config_bridge.py +221 -0
- pve_sync_plugin/sync/engine.py +50 -0
- pve_sync_plugin/tables.py +278 -0
- pve_sync_plugin/tasks.py +440 -0
- pve_sync_plugin/template_content.py +44 -0
- pve_sync_plugin/templates/pve_sync/dashboard.html +400 -0
- pve_sync_plugin/templates/pve_sync/inc/vm_sync_button.html +7 -0
- pve_sync_plugin/templates/pve_sync/pbsserverconfig.html +69 -0
- pve_sync_plugin/templates/pve_sync/pvebackupstatus.html +27 -0
- pve_sync_plugin/templates/pve_sync/pveclusterconfig.html +52 -0
- pve_sync_plugin/templates/pve_sync/pvedriftevent.html +89 -0
- pve_sync_plugin/templates/pve_sync/pvesyncjob.html +197 -0
- pve_sync_plugin/templates/pve_sync/pvewebhookevent.html +42 -0
- pve_sync_plugin/templates/pve_sync/settings.html +79 -0
- pve_sync_plugin/templatetags/__init__.py +1 -0
- pve_sync_plugin/templatetags/pve_sync_tags.py +86 -0
- pve_sync_plugin/urls.py +186 -0
- pve_sync_plugin/utils.py +84 -0
- pve_sync_plugin/view_helpers.py +98 -0
- pve_sync_plugin/views.py +535 -0
- 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)
|