Módulo VPSManager para WHMCS con configuración externa, acceso seguro al panel y métricas Graphite para LX.
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 
 
 
 

127 lignes
6.6 KiB

  1. <?php
  2. namespace VPSManager;
  3. final class BackupParser
  4. {
  5. private static function text(\DOMNode $node): string
  6. {
  7. return trim(preg_replace('/\s+/u', ' ', $node->textContent));
  8. }
  9. public static function number(?string $value): ?float
  10. {
  11. if ($value === null || !preg_match('/\A[0-9]+(?:\.[0-9]+)?\z/', $value)) return null;
  12. $n = (float) $value;
  13. return is_finite($n) ? $n : null;
  14. }
  15. public static function timestamp(string $value, \DateTimeZone $zone): int
  16. {
  17. $date = \DateTimeImmutable::createFromFormat('!Y-m-d H:i', $value, $zone);
  18. if (!$date || $date->format('Y-m-d H:i') !== $value) throw new \RuntimeException('DATE');
  19. // A repeated local minute cannot be assigned an offset from these HTML exports.
  20. $stamp = $date->getTimestamp();
  21. $transitions = $zone->getTransitions($stamp - 86400, $stamp + 86400);
  22. foreach ($transitions ?: [] as $transition) {
  23. $candidate = $stamp + $date->getOffset() - $transition['offset'];
  24. if ($candidate !== $stamp && (new \DateTimeImmutable('@' . $candidate))->setTimezone($zone)->format('Y-m-d H:i') === $value) {
  25. throw new \RuntimeException('DATE_AMBIGUOUS');
  26. }
  27. }
  28. return $stamp;
  29. }
  30. public static function parse(string $html, string $host, string $timezone): array
  31. {
  32. if (!Backups::validHost($host) || !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) throw new \RuntimeException('CONFIG');
  33. $zone = new \DateTimeZone($timezone);
  34. if (!class_exists('DOMDocument')) throw new \RuntimeException('DOM');
  35. $previous = libxml_use_internal_errors(true);
  36. try {
  37. $doc = new \DOMDocument();
  38. $doc->resolveExternals = false;
  39. $doc->substituteEntities = false;
  40. if (!$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) throw new \RuntimeException('HTML');
  41. } finally {
  42. libxml_clear_errors();
  43. libxml_use_internal_errors($previous);
  44. }
  45. $xp = new \DOMXPath($doc);
  46. $headings = $xp->query('//h1');
  47. if ($headings->length !== 1 || self::text($headings->item(0)) !== 'Host ' . $host . ' Backup Summary') throw new \RuntimeException('IDENTITY');
  48. $sets = [];
  49. foreach ($xp->query('//table') as $table) {
  50. $headers = null; $kind = null; $rows = [];
  51. foreach ($xp->query('./tr|./thead/tr|./tbody/tr', $table) as $row) {
  52. $cells = [];
  53. foreach ($xp->query('./td|./th', $row) as $cell) $cells[] = self::text($cell);
  54. if (($cells[0] ?? '') === 'Backup#') {
  55. if ($headers !== null) throw new \RuntimeException('DUPLICATE_HEADER');
  56. $headers = $cells;
  57. if (in_array('Start Date', $cells, true) && in_array('Duration/mins', $cells, true)) $kind = 'copies';
  58. elseif (in_array('#Xfer errs', $cells, true)) $kind = 'errors';
  59. elseif (array_slice($cells, 0, 5) === ['Backup#', 'Type', '#Files', 'Size/MiB', 'MiB/sec']) {
  60. // Verify that the first size column actually belongs to Totals.
  61. $first = $xp->query('./tr|./thead/tr|./tbody/tr', $table)->item(0);
  62. $groups = $xp->query('./td|./th', $first);
  63. if ($groups->length >= 2 && self::text($groups->item(1)) === 'Totals'
  64. && $groups->item(0)->getAttribute('colspan') === '2'
  65. && $groups->item(1)->getAttribute('colspan') === '3') $kind = 'sizes';
  66. }
  67. continue;
  68. }
  69. if ($kind === null) continue;
  70. if (!preg_match('/\A[0-9]+\z/', $cells[0] ?? '')) throw new \RuntimeException('ROW');
  71. $id = $cells[0];
  72. if (isset($rows[$id])) throw new \RuntimeException('DUPLICATE');
  73. if (count($cells) !== count($headers)) {
  74. if ($kind === 'copies') throw new \RuntimeException('ROW_WIDTH');
  75. $rows[$id] = [];
  76. continue;
  77. }
  78. $data = [];
  79. foreach ($headers as $i => $name) if (!array_key_exists($name, $data)) $data[$name] = $cells[$i] ?? null;
  80. $rows[$id] = $data;
  81. }
  82. if ($kind !== null) {
  83. if (isset($sets[$kind])) throw new \RuntimeException('TABLE_DUPLICATE');
  84. $sets[$kind] = $rows;
  85. }
  86. }
  87. if (!isset($sets['copies'])) throw new \RuntimeException('MISSING_TABLE');
  88. // Recognized empty summary table is explicit evidence of zero retained rows.
  89. $copies = []; $full = 0; $incremental = 0;
  90. foreach ($sets['copies'] as $id => $row) {
  91. $type = ['full' => 'Completa', 'incr' => 'Incremental'][$row['Type'] ?? ''] ?? 'Información no disponible';
  92. if ($type === 'Completa') $full++;
  93. if ($type === 'Incremental') $incremental++;
  94. $errors = $sets['errors'][$id] ?? [];
  95. $counters = [];
  96. foreach (['#Xfer errs', '#bad files', '#bad share', '#tar errs'] as $key) {
  97. $v = $errors[$key] ?? null;
  98. $counters[] = is_string($v) && preg_match('/\A[0-9]+\z/', $v) ? self::number($v) : null;
  99. }
  100. $incidents = 'Información no disponible';
  101. if (count(array_filter($counters, static fn($v) => $v !== null && $v > 0))) $incidents = 'Con incidencias';
  102. elseif (!in_array(null, $counters, true)) $incidents = 'Sin incidencias registradas';
  103. $size = self::number($sets['sizes'][$id]['Size/MiB'] ?? null);
  104. $copies[] = [
  105. 'start' => self::timestamp($row['Start Date'] ?? '', $zone),
  106. 'type' => $type,
  107. 'duration_minutes' => self::number($row['Duration/mins'] ?? null),
  108. 'size_gib' => $size === null ? null : $size / 1024,
  109. 'incidents' => $incidents,
  110. ];
  111. }
  112. usort($copies, static fn($a, $b) => $b['start'] <=> $a['start']);
  113. $lastFull = null;
  114. foreach ($copies as $copy) if ($copy['type'] === 'Completa') { $lastFull = $copy['start']; break; }
  115. $activity = null;
  116. foreach ($xp->query('//li') as $li) {
  117. if (preg_match('/\ALast status is state "idle" \(done\) as of [0-9 :.-]+\.\z/', self::text($li))) $activity = 'Sin actividad en curso';
  118. }
  119. return ['total' => count($copies), 'full' => $full, 'incremental' => $incremental,
  120. 'last_full' => $lastFull, 'activity' => $activity, 'timezone' => $timezone, 'copies' => $copies];
  121. }
  122. }