PluginProbe
JetBackup – Backup, Restore & Migrate / 3.1.21.3
JetBackup – Backup, Restore & Migrate v3.1.21.3
3.1.23.6 3.1.23.5 3.1.23.3 3.1.22.4 3.1.22.3 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.8.1 1.4.9 1.5.0 1.5.1 1.5.1.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 1.6.10 All 82 releases
backup / src / JetBackup / Destination / Destination.php
Destination.php
739 lines 21.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace JetBackup\Destination;
4
5 use Exception;
6 use JetBackup\BackupJob\BackupJob;
7 use JetBackup\Cron\Task\Task;
8 use JetBackup\Data\DBObject;
9 use JetBackup\Data\SleekStore;
10 use JetBackup\Destination\Integration\DestinationDirIterator;
11 use JetBackup\Destination\Vendors\JetStorage\JetStorage;
12 use JetBackup\Destination\Vendors\Imported\Imported;
13 use JetBackup\Encryption\Crypt;
14 use JetBackup\Destination\Vendors\Local\Local;
15 use JetBackup\Entities\Util;
16 use JetBackup\Exception\AjaxException;
17 use JetBackup\Exception\ConnectionException;
18 use JetBackup\Exception\DBException;
19 use JetBackup\Exception\DestinationException;
20 use JetBackup\Exception\ExecutionTimeException;
21 use JetBackup\Exception\FieldsValidationException;
22 use JetBackup\Exception\IOException;
23 use JetBackup\Exception\QueueException;
24 use JetBackup\Exception\RegistrationException;
25 use JetBackup\Exception\ValidationException;
26 use JetBackup\Factory;
27 use JetBackup\Destination\Integration\Destination as iDestination;
28 use JetBackup\Filesystem\File;
29 use JetBackup\JetBackup;
30 use JetBackup\Log\LogController;
31 use JetBackup\Queue\Queue;
32 use JetBackup\Queue\QueueItem;
33 use JetBackup\Queue\QueueItemReindex;
34 use JetBackup\ResumableTask\ResumableTask;
35 use JetBackup\Web\File\FileChunkIterator;
36 use JetBackup\Web\File\FileStream;
37 use SleekDB\Exceptions\InvalidArgumentException;
38 use SleekDB\QueryBuilder;
39
40 if (!defined( '__JETBACKUP__')) die('Direct access is not allowed');
41
42 class Destination extends DBObject {
43
44 const COLLECTION = 'destinations';
45
46 const PROTECTED_FIELD = 'JB|HIDDEN|********************';
47
48 const UNIQUE_ID = 'unique_id';
49 const NAME = 'name';
50 const TYPE = 'type';
51 const NOTES = 'notes';
52 const PATH = 'path';
53 const ENABLED = 'enabled';
54 const READ_ONLY = 'read_only';
55 const FREE_DISK = 'free_disk';
56 const CHUNK_SIZE = 'chunk_size';
57 const EXPORT_CONFIG = 'export_config';
58 const JOBS_ASSIGNED = 'jobs_assigned';
59 const OPTIONS = 'options';
60 const DEFAULT = 'default';
61
62 const LICENSE_EXCLUDED = [Local::TYPE, JetStorage::TYPE, Imported::TYPE];
63
64 private ?iDestination $_destination=null;
65 private ?LogController $_logController=null;
66
67 /**
68 * @param $_id
69 *
70 * @throws InvalidArgumentException
71 * @throws DBException
72 * @throws \SleekDB\Exceptions\IOException
73 */
74 public function __construct($_id=null) {
75 parent::__construct(self::COLLECTION);
76 if($_id) $this->_loadById((int) $_id);
77 }
78
79 /**
80 * @param $id
81 *
82 * @return void
83 */
84 public function setUniqueId($id) { $this->set(self::UNIQUE_ID, $id); }
85
86 /**
87 * @return string
88 */
89 public function getUniqueId():string { return $this->get(self::UNIQUE_ID); }
90
91 /**
92 * @param string $name
93 *
94 * @return void
95 */
96 public function setName(string $name) { $this->set(self::NAME, $name); }
97
98 /**
99 * @return string
100 */
101 public function getName():string { return $this->get(self::NAME); }
102
103 /**
104 * @param string $path
105 *
106 * @return void
107 */
108 public function setPath(string $path) { $this->set(self::PATH, $path); }
109
110 /**
111 * @return string
112 */
113 public function getPath():string { return $this->get(self::PATH); }
114
115 /**
116 * @param string $notes
117 *
118 * @return void
119 */
120 public function setNotes(string $notes) { $this->set(self::NOTES, $notes); }
121
122 /**
123 * @return string
124 */
125 public function getNotes():string { return $this->get(self::NOTES); }
126
127 /**
128 * @param object $options
129 *
130 * @return void
131 * @throws DestinationException
132 */
133 public function setOptions(object $options):void { $this->getInstance()->setData($options); }
134
135 /**
136 * @return object|array
137 * @throws DestinationException
138 */
139 public function getOptions(): object { return (object) $this->getInstance()->getData(); }
140
141 /**
142 * @param int $disk
143 *
144 * @return void
145 */
146 public function setFreeDisk(int $disk) { $this->set(self::FREE_DISK, $disk); }
147
148 /**
149 * @return int
150 */
151 public function getFreeDisk():int { return (int) $this->get(self::FREE_DISK, 0); }
152
153 /**
154 * @param bool $enabled
155 *
156 * @return void
157 */
158 public function setEnabled(bool $enabled) { $this->set(self::ENABLED, $enabled); }
159
160 /**
161 * @return bool
162 */
163 public function isEnabled():bool { return !!$this->get(self::ENABLED, true); }
164
165 /**
166 * @param bool $readonly
167 *
168 * @return void
169 */
170 public function setReadOnly(bool $readonly) { $this->set(self::READ_ONLY, $readonly); }
171 public function setDefault(bool $default) { $this->set(self::DEFAULT, $default); }
172 public function isDefault():bool { return $this->get(self::DEFAULT, false); }
173 /**
174 * @return bool
175 */
176 public function isReadOnly():bool { return !!$this->get(self::READ_ONLY, false); }
177
178 /**
179 * @param bool $export
180 *
181 * @return void
182 */
183 public function setExportConfig(bool $export) { $this->set(self::EXPORT_CONFIG, $export); }
184
185 /**
186 * @return bool
187 */
188 public function isExportConfig():bool { return !!$this->get(self::EXPORT_CONFIG, true); }
189
190 public function getDecryptedOptions():string {
191 $option = $this->get(self::OPTIONS);
192 return $option ? Crypt::decrypt($option, Factory::getConfig()->getEncryptionKey()) : '';
193 }
194
195 /**
196 * @throws InvalidArgumentException
197 * @throws \SleekDB\Exceptions\IOException
198 * @throws DBException
199 */
200 public static function getIsDefault(?int $destinationId): bool {
201 if (!$destinationId) return false;
202 $destination = new self($destinationId);
203 return $destination->isDefault();
204 }
205
206 /**
207 * @param LogController $logController
208 *
209 * @return void
210 */
211 public function setLogController(LogController $logController):void { $this->_logController = $logController; }
212
213 /**
214 * @return LogController
215 */
216 public function getLogController(): LogController {
217 if(!$this->_logController) $this->_logController = new LogController();
218 return $this->_logController;
219 }
220
221 /**
222 * @return string
223 */
224 public function getType():string { return $this->get(self::TYPE); }
225
226 /**
227 * @param string $type
228 *
229 * @return void
230 */
231 public function setType(string $type):void { $this->set(self::TYPE, $type); }
232
233 /**
234 * By default, chunk size is saved in the DB as MB representation (1 for 1MB, 2 for 2MB)
235 * for size in bytes we use getChunkSizeBytes
236 * @return int
237 */
238 public function getChunkSize(): int { return $this->get(self::CHUNK_SIZE, 1); }
239
240 /**
241 * returns chunk size from DB in bytes
242 * @return int
243 */
244 public function getChunkSizeBytes(): int { return ($this->getChunkSize()) * 1024 * 1024; }
245
246 /**
247 * @param int $size
248 *
249 * @return void
250 */
251 public function setChunkSize(int $size):void { $this->set(self::CHUNK_SIZE, $size); }
252
253 /**
254 * @param $file
255 *
256 * @return bool
257 * @throws DestinationException
258 * @throws IOException
259 */
260 public function fileExists($file):bool {
261 return $this->getInstance()->fileExists($file);
262 }
263
264 /**
265 * @param $directory
266 *
267 * @return bool
268 * @throws DestinationException
269 * @throws IOException
270 */
271 public function dirExists($directory):bool {
272 return $this->getInstance()->dirExists($directory);
273 }
274
275 /**
276 * @param $directory
277 *
278 * @return DestinationDirIterator
279 * @throws DestinationException
280 */
281 public function listDir($directory):DestinationDirIterator {
282 return $this->getInstance()->listDir($directory);
283 }
284
285 /**
286 * @throws IOException
287 * @throws DestinationException
288 */
289
290 /**
291 * @return Destination|null
292 * @throws InvalidArgumentException
293 * @throws \SleekDB\Exceptions\IOException|DBException
294 */
295 public static function getDefaultDestination(): ?Destination {
296
297 $result = self::query()
298 ->select([JetBackup::ID_FIELD])
299 ->where([ self::TYPE, "=", Local::TYPE ])
300 ->where([ self::DEFAULT, "=", true ])
301 ->getQuery()
302 ->first();
303
304 return $result ? new Destination( $result[ JetBackup::ID_FIELD]) : null;
305
306 }
307
308 /**
309 * @throws InvalidArgumentException
310 * @throws \SleekDB\Exceptions\IOException|DestinationException|DBException
311 */
312 public static function createDefaultDestination():Destination {
313
314 if($destination = self::getDefaultDestination()) return $destination;
315
316 $config = new Destination();
317 $config->setType(Local::TYPE);
318 $config->setName('Default');
319 $config->setDefault(true);
320 $config->setPath('/');
321 $config->setChunkSize(1);
322 $config->setExportConfig(false); // since case #1007 it's disabled by default
323 $config->save();
324
325 return $config;
326
327 }
328
329 /**
330 * Get the Imported destination for storing user-uploaded backup files.
331 * Returns null if it doesn't exist yet.
332 *
333 * @return Destination|null
334 * @throws InvalidArgumentException
335 * @throws \SleekDB\Exceptions\IOException|DBException
336 */
337 public static function getImportedDestination(): ?Destination {
338
339 $result = self::query()
340 ->select([JetBackup::ID_FIELD])
341 ->where([ self::TYPE, "=", Imported::TYPE ])
342 ->getQuery()
343 ->first();
344
345 return $result ? new Destination( $result[ JetBackup::ID_FIELD]) : null;
346
347 }
348
349 /**
350 * Create or get the Imported destination for storing user-uploaded backup files.
351 * This destination is automatically created on first import.
352 *
353 * @throws InvalidArgumentException
354 * @throws \SleekDB\Exceptions\IOException|DestinationException|DBException
355 */
356 public static function createImportedDestination():Destination {
357
358 if($destination = self::getImportedDestination()) return $destination;
359
360 $config = new Destination();
361 $config->setType(Imported::TYPE);
362 $config->setName('Imported Backups');
363 $config->setPath('/');
364 $config->setChunkSize(1);
365 $config->setExportConfig(false);
366 $config->setEnabled(true);
367 $config->setReadOnly(true); // Imported destination should be read-only for normal operations
368 $config->save();
369
370 return $config;
371
372 }
373
374 public function createDir($directory) {
375 $this->getInstance()->createDir($directory, true);
376 }
377
378 /**
379 * @throws IOException
380 * @throws DestinationException
381 */
382 public function removeDir(string $directory):void {
383 $this->getInstance()->removeDir($directory);
384 }
385
386 /**
387 * @param string $source
388 * @param string $destination
389 * @param QueueItem|null $queue_item
390 * @param Task|null $task
391 *
392 * @return void
393 * @throws DestinationException
394 * @throws IOException
395 * @throws InvalidArgumentException
396 * @throws ExecutionTimeException
397 * @throws \SleekDB\Exceptions\IOException
398 */
399 public function copyFileToLocal(string $source, string $destination, ?QueueItem $queue_item=null, ?Task $task=null):void {
400
401 if(!($file = $this->getInstance()->getFileStat($source))) throw new IOException("The provided file doesn't exist");
402
403 if($file->getSize() > $this->getChunkSizeBytes()) {
404
405 // Download in chunks
406 $this->getLogController()->logMessage("Downloading file $source to $destination in chunks");
407 $this->getLogController()->logDebug("[copyFileToLocal] Chunk size: {$this->getChunkSizeBytes()}");
408
409 $download = $this->getInstance()->copyFileToLocalChunked($source, $destination);
410 $offset = file_exists($destination) ? filesize($destination) : 0;
411 $this->getLogController()->logDebug("[copyFileToLocal] [CHUNKED] Offset: $offset");
412
413 while ($offset < $file->getSize()) {
414 if($task) $task->checkExecutionTime();
415 $chunkSize = min($this->getChunkSizeBytes(), $file->getSize() - $offset);
416 $read = $download->download($offset, $offset + $chunkSize);
417 $offset += $read;
418
419 if($queue_item) {
420 $progress = $queue_item->getProgress();
421 $progress->setCurrentSubItem($progress->getCurrentSubItem()+$read);
422 $queue_item->save();
423 }
424
425 $this->getLogController()->logDebug( "[copyFileToLocal] [CHUNKED] Progress: {$offset}/{$file->getSize()}" );
426 }
427 } else {
428 // Download regular
429 $this->getLogController()->logMessage("Downloading file $source to $destination");
430 if(file_exists($destination)) {
431 $this->getLogController()->logDebug("[copyFileToLocal] Destination file exists: $destination, DELETING BEFORE DOWNLOAD");
432 unlink($destination);
433 }
434 $this->getInstance()->copyFileToLocal($source, $destination);
435
436 if($queue_item) {
437 $progress = $queue_item->getProgress();
438 $progress->setCurrentSubItem($progress->getCurrentSubItem()+filesize($destination));
439 $queue_item->save();
440 }
441 }
442 }
443
444 /**
445 * @param string $source
446 * @param string $destination
447 * @param ResumableTask $resumableTask
448 *
449 * @return void
450 * @throws DestinationException
451 * @throws IOException
452 * @throws Exception
453 */
454 public function copyFileToRemote(string $source, string $destination, ?QueueItem $queue_item=null, ?Task $task=null):void {
455
456 $file = new File($source);
457
458 if($file->isLink()) return;
459
460 if($file->size() > $this->getChunkSizeBytes()) {
461
462 // Upload in chunks
463 $this->getLogController()->logMessage("[copyFileToRemote] [CHUNKED] Uploading file $source to $destination");
464 $this->getLogController()->logDebug("[copyFileToRemote] [CHUNKED] Chunk size: " . $this->getChunkSizeBytes());
465
466 $upload = $this->getInstance()->copyFileToRemoteChunked($source, $destination);
467
468 $resumableTask = $queue_item ? $queue_item->getResumableTask() : new ResumableTask(sha1($destination));
469 $resumableTask->setLogController($this->getLogController());
470
471 $data = $resumableTask->func([$upload, 'prepare'], [], 'prepare_' . $source . '|' . $this->getId());
472 $upload->setData((object) $data);
473
474 $resumableTask->func(function() use ($source, $upload, $queue_item, $task) {
475
476 $offset = $upload->getOffset();
477 $chunkSize = $upload->getChunkSize() ?: $this->getChunkSizeBytes();
478 $this->getLogController()->logDebug("[copyFileToRemote] [CHUNKED] Offset: " . $offset);
479
480 // On resume, previous cycles already counted the offset bytes in progress.
481 // Subtract them so they aren't double-counted when we re-add per chunk.
482 if($offset && $queue_item) {
483 $progress = $queue_item->getProgress();
484 $progress->setCurrentSubItem(max(0, $progress->getCurrentSubItem() - $offset));
485 $queue_item->save();
486 }
487
488 $file = new FileStream($source);
489 if($offset) $file->seek($offset);
490
491 $iterator = new FileChunkIterator($file, $chunkSize);
492
493 while($iterator->hasNext()) {
494 $this->getLogController()->logDebug("[copyFileToRemote] [CHUNKED] Sending chunk {$file->tell()}/{$file->getSize()}");
495 if($task) $task->checkExecutionTime();
496 $chunk = $iterator->next();
497 $upload->upload($chunk);
498
499 if($queue_item) {
500 $progress = $queue_item->getProgress();
501 $progress->setCurrentSubItem($progress->getCurrentSubItem()+$chunk->getSize());
502 $queue_item->save();
503 }
504 }
505 }, [], 'upload_' . $source . '|' . $this->getId());
506
507 $resumableTask->func([ $upload, 'finalize' ], [], 'finalize_' . $source . '|' . $this->getId());
508
509 } else {
510 // Upload regular
511 $this->getLogController()->logMessage("[copyFileToRemote] Uploading file $source to $destination");
512 $this->getInstance()->copyFileToRemote($source, $destination);
513
514 if($queue_item) {
515 $progress = $queue_item->getProgress();
516 $progress->setCurrentSubItem($progress->getCurrentSubItem()+filesize($source));
517 $queue_item->save();
518 }
519 }
520 }
521
522 /**
523 * @return iDestination
524 * @throws DestinationException
525 */
526 public function getInstance():iDestination {
527 if(!$this->_destination) {
528 $type = $this->getType();
529 $method = "\JetBackup\Destination\Vendors\\$type\\$type";
530 if(!$type || !class_exists($method)) throw new DestinationException("Destination type not found ($type)");
531 $this->_destination = new $method($this->getChunkSizeBytes(), $this->getPath(), $this->getLogController(), $this->getName() ?? null, $this->getId());
532 $decrypted_options = $this->getDecryptedOptions();
533 if($decrypted_options) $this->_destination->setSerializedData($decrypted_options);
534 }
535 return $this->_destination;
536 }
537
538 public function updateSerializedData(string $data):void {
539 $this->set(self::OPTIONS, Crypt::encrypt($data, Factory::getConfig()->getEncryptionKey()));
540 parent::save();
541 }
542
543 /**
544 * @return void
545 * @throws ConnectionException
546 * @throws DestinationException
547 */
548 public function connect():void { $this->getInstance()->connect(); }
549
550 /**
551 * @return void
552 * @throws DestinationException
553 */
554 public function disconnect():void { $this->getInstance()->disconnect(); }
555
556 /**
557 * @return void
558 * @throws DestinationException
559 * @throws RegistrationException
560 */
561 public function register():void { $this->getInstance()->register(); }
562
563 /**
564 * @return void
565 * @throws DestinationException
566 */
567 public function unregister():void { $this->getInstance()->unregister(); }
568
569 /**
570 * @return array
571 * @throws DestinationException
572 */
573 public function protectedFields():array { return $this->getInstance()->protectedFields(); }
574
575 /**
576 * @return void
577 * @throws DestinationException
578 */
579 public function save():void {
580 if(!$this->getUniqueId()) $this->setUniqueId(Util::generateUniqueId());
581 $this->updateSerializedData($this->getInstance()->getSerializedData());
582 }
583
584 /**
585 * @return SleekStore
586 */
587 public static function db():SleekStore {
588 return new SleekStore(self::COLLECTION);
589 }
590
591 /**
592 * @return QueryBuilder
593 */
594 public static function query():QueryBuilder {
595 return self::db()->createQueryBuilder();
596 }
597
598 /**
599 * @return void
600 * @throws ValidationException|DestinationException
601 */
602 public function validate():void {
603 $instance = $this->getInstance();
604
605 try {
606 $instance->connect();
607 $instance->disconnect();
608 } catch(ConnectionException $e) {
609 throw new ValidationException($e->getMessage());
610 }
611 }
612
613 /**
614 * @param bool $cross_domain
615 * @param bool $recursive
616 *
617 * @return void
618 * @throws InvalidArgumentException
619 * @throws QueueException
620 * @throws \SleekDB\Exceptions\IOException
621 */
622 public function addToQueue( bool $cross_domain=false):void {
623
624 $reindex = new QueueItemReindex();
625 $reindex->setDestinationId($this->getId());
626 $reindex->setCrossDomain($cross_domain ?: Factory::getSettingsRestore()->isRestoreAllowCrossDomain());
627 $queue_item = QueueItem::prepare();
628 $queue_item->setType(Queue::QUEUE_TYPE_REINDEX);
629 $queue_item->setItemId($this->getId());
630 $queue_item->setItemData($reindex);
631
632 Queue::addToQueue($queue_item);
633 }
634
635 /**
636 * @throws \SleekDB\Exceptions\IOException
637 * @throws InvalidArgumentException
638 */
639 private function _findPathByType(): array {
640 return Destination::query()
641 ->where([ Destination::TYPE, "=", $this->getType()])
642 ->where([Destination::PATH, '=', $this->getPath()])
643 ->where([JetBackup::ID_FIELD, '!=', $this->getId()])
644 ->getQuery()
645 ->fetch();
646 }
647
648 /**
649 * @return void
650 * @throws DBException
651 * @throws DestinationException
652 * @throws InvalidArgumentException
653 * @throws ValidationException
654 * @throws \SleekDB\Exceptions\IOException|FieldsValidationException
655 */
656 public function validateFields():void {
657
658 if(!$this->getName()) throw new FieldsValidationException("Name is required");
659 if(!$this->getChunkSize()) throw new FieldsValidationException("Chunk size is required");
660 if(!$this->getPath()) throw new FieldsValidationException("Backup directory cannot be empty");
661 if(sizeof($this->_findPathByType())) throw new FieldsValidationException("Destination path '{$this->getPath()}' for destination type '{$this->getType()}' already in use");
662
663 if (preg_match('#^[^/.a-zA-Z0-9_-]+$#', $this->getPath()))
664 throw new FieldsValidationException('Backup directory name can only contain letters, numbers, periods, underscores, dashes and slashes.');
665
666 if (preg_match('#(/\.\./|^\.\./|/\.{2}/|/\.\.$)#', $this->getPath()))
667 throw new FieldsValidationException('Backup directory contains invalid directory traversal patterns.');
668
669 if($this->getId()) {
670 $destination = new Destination($this->getId());
671 if($destination->getPath() != $this->getPath()) throw new FieldsValidationException("Cannot change backup directory for an active destination");
672 }
673
674 $this->getInstance()->validateFields();
675 }
676
677 /**
678 * @throws \SleekDB\Exceptions\IOException
679 * @throws InvalidArgumentException
680 * @throws DestinationException
681 */
682 public function getDisplay(): array {
683
684 $jobs_assigned = count(BackupJob::query()
685 ->where([ BackupJob::DESTINATIONS, "contains", $this->getId()])
686 ->where([BackupJob::HIDDEN, '=', false])
687 ->getQuery()
688 ->fetch());
689
690 $output = [
691 JetBackup::ID_FIELD => $this->getId(),
692 self::UNIQUE_ID => $this->getUniqueId(),
693 self::NAME => $this->getName(),
694 self::PATH => $this->getPath(),
695 self::TYPE => $this->getType(),
696 self::NOTES => $this->getNotes(),
697 self::ENABLED => $this->isEnabled(),
698 self::READ_ONLY => $this->isReadOnly(),
699 self::FREE_DISK => $this->getFreeDisk(),
700 self::EXPORT_CONFIG => $this->isExportConfig() ? 1 : 0,
701 self::OPTIONS => (array) $this->getOptions(),
702 self::CHUNK_SIZE => $this->getChunkSize(),
703 self::DEFAULT => $this->isDefault(),
704 self::JOBS_ASSIGNED => $jobs_assigned
705 ];
706
707 $fields = $this->protectedFields();
708 foreach($fields as $field) $output[self::OPTIONS][$field] = self::PROTECTED_FIELD;
709
710 return $output;
711 }
712
713 /**
714 * @return array
715 * @throws InvalidArgumentException
716 * @throws \SleekDB\Exceptions\IOException
717 */
718 public function getDisplayCLI(): array {
719
720 $jobs_assigned = count(BackupJob::query()
721 ->where([ BackupJob::DESTINATIONS, "contains", $this->getId()])
722 ->getQuery()
723 ->fetch());
724
725 return [
726 'ID' => $this->getId(),
727 'Name' => $this->getName(),
728 'Path' => $this->getPath(),
729 'Type' => $this->getType(),
730 'Notes' => $this->getNotes(),
731 'Enabled' => $this->isEnabled() ? 'Yes' : 'No',
732 'Read Only' => $this->isReadOnly() ? 'Yes' : 'No',
733 'Default' => $this->isDefault() ? 'Yes' : 'No',
734 'Limit Disk' => $this->getFreeDisk() ? $this->getFreeDisk() . '%' : 'Disabled',
735 'Export Config' => $this->isExportConfig() ? 'Yes' : 'No',
736 'Jobs Assigned' => $jobs_assigned,
737 ];
738 }
739 }