Skip to content

Commit dfd36db

Browse files
committed
fix(admin): polish backup recovery UI and health separation
1 parent abcbb9d commit dfd36db

8 files changed

Lines changed: 250 additions & 112 deletions

File tree

‎admin/assets/admin.css‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,8 @@ tr:last-child td {
572572
.backup-summary {
573573
display: grid;
574574
min-width: 0;
575+
min-height: 92px;
576+
align-content: start;
575577
gap: 7px;
576578
padding: 15px;
577579
border: 1px solid var(--line);
@@ -626,6 +628,25 @@ tr:last-child td {
626628
overflow-y: hidden;
627629
overscroll-behavior-inline: contain;
628630
}
631+
.recovery-points-scroll {
632+
--recovery-visible-rows: 10;
633+
--recovery-row-block-size: 3.75rem;
634+
max-height: calc(var(--recovery-row-block-size) * (var(--recovery-visible-rows) + 1));
635+
overflow: auto;
636+
overscroll-behavior: contain;
637+
scrollbar-gutter: stable;
638+
}
639+
.recovery-points-scroll:focus-visible {
640+
outline: 2px solid var(--accent);
641+
outline-offset: 2px;
642+
}
643+
.recovery-points-scroll thead th {
644+
position: sticky;
645+
z-index: 1;
646+
top: 0;
647+
background: var(--surface);
648+
box-shadow: 0 1px 0 var(--line);
649+
}
629650
.backup-table th:nth-child(1) { width: 31%; }
630651
.backup-table th:nth-child(2) { width: 23%; }
631652
.backup-table th:nth-child(3) { width: 15%; }
@@ -654,6 +675,18 @@ tr:last-child td {
654675
background: #fff1d7;
655676
color: var(--warning);
656677
}
678+
.badge.change-added {
679+
background: var(--accent-soft);
680+
color: #176b59;
681+
}
682+
.badge.change-modified {
683+
background: #fff1d7;
684+
color: var(--warning);
685+
}
686+
.badge.change-deleted {
687+
background: #fdf0ef;
688+
color: #842d26;
689+
}
657690
.restore-file-row {
658691
display: grid;
659692
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -748,6 +781,41 @@ dialog.backup-dialog {
748781
.backup-dialog__surface {
749782
max-height: calc(100dvh - 32px);
750783
}
784+
.backup-change-section {
785+
display: grid;
786+
min-width: 0;
787+
gap: 9px;
788+
}
789+
.backup-change-section h3 {
790+
margin: 0;
791+
font-size: 14px;
792+
}
793+
.backup-change-list {
794+
display: grid;
795+
max-height: min(15rem, 32vh);
796+
margin: 0;
797+
padding: 0;
798+
overflow-y: auto;
799+
border: 1px solid var(--line);
800+
border-radius: 9px;
801+
list-style: none;
802+
overscroll-behavior: contain;
803+
}
804+
.backup-change-list li {
805+
display: grid;
806+
grid-template-columns: auto minmax(0, 1fr);
807+
align-items: center;
808+
gap: 10px;
809+
padding: 10px 12px;
810+
}
811+
.backup-change-list li + li {
812+
border-top: 1px solid var(--line);
813+
}
814+
.backup-change-list code {
815+
min-width: 0;
816+
overflow-wrap: anywhere;
817+
color: var(--ink);
818+
}
751819
.dialog-detail-list {
752820
display: grid;
753821
grid-template-columns: minmax(130px, 190px) minmax(0, 1fr);
@@ -829,6 +897,14 @@ dialog.backup-dialog {
829897
color: var(--muted);
830898
text-align: center;
831899
}
900+
.empty strong,
901+
.empty span {
902+
display: block;
903+
}
904+
.empty strong {
905+
margin-bottom: 6px;
906+
color: var(--ink);
907+
}
832908

833909
.pre-auth {
834910
background: radial-gradient(circle at top, #e6f4f1 0, var(--canvas) 42%);

‎admin/assets/admin.js‎

Lines changed: 117 additions & 79 deletions
Large diffs are not rendered by default.

‎app/Backup/ApplicationBackupManager.php‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,25 @@ public function preview(string $archivePath): array
121121
$manifest = $this->verifyArchive($archivePath, true);
122122
$changed = [];
123123
$unchanged = [];
124+
$changes = [];
124125
foreach ($manifest['files'] as $file) {
125126
$logical = $file['path'];
126127
$current = $this->loadAndValidateSource($logical, $this->sources[$logical], true);
127128
$same = hash_equals(hash('sha256', $this->encodeJson($current)), $file['sha256']);
128-
if ($same) $unchanged[] = $logical; else $changed[] = $logical;
129+
if ($same) {
130+
$unchanged[] = $logical;
131+
} else {
132+
$changed[] = $logical;
133+
$changes[] = ['path' => $logical, 'type' => 'modified'];
134+
}
129135
}
130136
return [
131137
'recoveryPointId' => $manifest['recoveryPointId'], 'createdAt' => $manifest['createdAt'],
132138
'applicationVersion' => $manifest['application']['version'], 'formatVersion' => $manifest['formatVersion'],
133139
'trigger' => $manifest['trigger'] ?? 'legacy', 'createdBy' => $manifest['createdBy'] ?? null,
134-
'files' => array_column($manifest['files'], 'path'), 'changedFiles' => $changed, 'unchangedFiles' => $unchanged,
140+
'files' => array_column($manifest['files'], 'path'), 'configurationFiles' => count($manifest['files']),
141+
'changedFiles' => $changed, 'filesChanging' => count($changes), 'changes' => $changes,
142+
'unchangedFiles' => $unchanged,
135143
'schemaCompatible' => true, 'encryptionKeyAvailable' => true,
136144
'verification' => 'valid', 'authenticity' => 'valid', 'warnings' => [],
137145
];

‎app/Health/ApplicationHealthMonitor.php‎

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ final class ApplicationHealthMonitor
1818
private string $runtimeDirectory;
1919
private string $logDirectory;
2020
private string $sessionDirectory;
21-
private ?string $backupDirectory;
2221
private string $databaseCachePath;
2322
private int $databaseCacheTtl;
2423
private int $diskWarningBytes;
@@ -40,9 +39,6 @@ public function __construct(array $options = [])
4039
$savePath = (string)end($parts);
4140
}
4241
$this->sessionDirectory = $savePath !== '' ? $savePath : sys_get_temp_dir();
43-
$configuredBackup = $options['backupDirectory'] ?? getenv('GENERIC_BACKUP_DIR');
44-
$this->backupDirectory = is_string($configuredBackup) && trim($configuredBackup) !== ''
45-
? rtrim(trim($configuredBackup), '/\\') : null;
4642
$this->databaseCachePath = $options['databaseCachePath']
4743
?? $this->runtimeDirectory . '/health/database-health.json';
4844
$this->databaseCacheTtl = max(1, (int)($options['databaseCacheTtl'] ?? 15));
@@ -94,7 +90,6 @@ public function detailed(array $processes): array
9490
'logging' => $this->loggingHealth(),
9591
'sessions' => $this->sessionHealth(),
9692
'encryption' => $this->encryptionHealth(),
97-
'backup' => $this->backupHealth(),
9893
'processes' => ['status' => $this->processAggregate($processes), 'services' => $processes],
9994
];
10095
$statuses = array_column($checks, 'status');
@@ -279,14 +274,6 @@ private function sessionHealth(): array
279274
? 'operational' : 'unavailable'];
280275
}
281276

282-
private function backupHealth(): array
283-
{
284-
if ($this->backupDirectory === null) return ['status' => 'degraded', 'category' => 'not_configured'];
285-
$check = $this->directoryHealth($this->backupDirectory, true);
286-
return ['status' => $check['status'] === 'healthy' ? 'healthy' : 'degraded',
287-
'category' => $check['status'] === 'healthy' ? 'capable' : 'unavailable'];
288-
}
289-
290277
private function directoryHealth(string $path, bool $writable): array
291278
{
292279
if (!is_dir($path)) return ['status' => 'unhealthy', 'category' => 'missing'];

‎docs/Monitoring-and-Health.md‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ Readiness validates the supported runtime configuration file set and versions,
3030
the writable runtime directory, the explicit database availability gate, and
3131
the ability to decrypt and validate database configuration. It deliberately
3232
does not open SQL connections. SQL Parser state is independent and cannot make
33-
the API unready. Logging and backup capability are diagnostic/degraded states,
34-
not readiness blockers.
33+
the API unready. Logging capability is a diagnostic/degraded state, not a
34+
readiness blocker. Backup scheduling and recovery-point status are reported
35+
only on Admin → Backup & Recovery and do not affect System Health.
3536

3637
The database readiness categories are `database_available`,
3738
`database_disconnected`, `configuration_missing`, `configuration_invalid`, and
@@ -55,8 +56,6 @@ safe checks for:
5556
- fail-open logging capability;
5657
- PHP session-directory availability;
5758
- encryption-key presence and usability, without exposing key material;
58-
- optional `GENERIC_BACKUP_DIR` capability, without creating or verifying a
59-
backup;
6059
- Admin/API/SQL Parser running, stopped, stale, crashed, or unresponsive state
6160
as determined by the existing process managers.
6261

@@ -70,14 +69,15 @@ Server connection load.
7069

7170
Directory checks are shallow: exists/readable/writable plus `disk_free_space`.
7271
They do not traverse files. Default warning and critical free-space thresholds
73-
are 1 GiB and 256 MiB; these classify diagnostics only. A missing logging or
74-
backup directory is degraded because audit logging is intentionally fail-open
75-
and backup execution is not part of request serving. A required runtime,
76-
configuration, session, or database dependency can be unhealthy.
77-
78-
`GENERIC_BACKUP_DIR` means only that an operator-designated backup destination
79-
is configured and writable. It does not prove that a recent or verified backup
80-
exists. Backup verification remains an explicit operator operation.
72+
are 1 GiB and 256 MiB; these classify diagnostics only. A missing logging
73+
directory is degraded because audit logging is intentionally fail-open. A
74+
required runtime, configuration, session, or database dependency can be
75+
unhealthy.
76+
77+
Removing backup scheduling from System Health does not affect restore safety.
78+
Every activation still runs `ApplicationHealthMonitor::restoreSafety()` after
79+
staging and validation; a failure triggers rollback to the previous
80+
configuration.
8181

8282
## Hosting layers
8383

‎tests/BackupRecoveryTest.php‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,9 @@ function backupCreateLegacyV2(string $source, string $target, string $encodedSig
178178
backupFailure(fn () => SafeZipArchive::create($backupParent . '/duplicate-too-large.zip', ['large.bin' => str_repeat('x', SafeZipArchive::MAX_ENTRY_BYTES + 1)]), 'Oversized ZIP entry was accepted.');
179179

180180
$preview = $manager->preview($bundlePath);
181-
backupAssert($preview['verification'] === 'valid' && $preview['authenticity'] === 'valid' && $preview['changedFiles'] === [], 'Restore preview is invalid.');
181+
backupAssert($preview['verification'] === 'valid' && $preview['authenticity'] === 'valid'
182+
&& $preview['configurationFiles'] === 6 && $preview['filesChanging'] === 0
183+
&& $preview['changedFiles'] === [] && $preview['changes'] === [], 'Restore preview is invalid.');
182184
$restorePath = $backupParent . '/restore-stage';
183185
$restore = $manager->stageRestore($bundlePath, $restorePath);
184186
backupAssert($restore['ready'] && $restore['files'] === 6 && is_file($restorePath . '/signature.json'), 'ZIP restore staging failed.');
@@ -187,7 +189,14 @@ function backupCreateLegacyV2(string $source, string $target, string $encodedSig
187189
$changedAdmin = AdminConfigurationRepository::defaults();
188190
$changedAdmin['server']['adminPort'] = 8099;
189191
backupWriteFixture($runtimePath . '/admin.json', $changedAdmin);
190-
backupAssert(in_array('config/admin.json', $manager->preview($bundlePath)['changedFiles'], true), 'Preview did not report changed configuration.');
192+
$changedPreview = $manager->preview($bundlePath);
193+
backupAssert($changedPreview['filesChanging'] === 1
194+
&& $changedPreview['changedFiles'] === ['config/admin.json']
195+
&& $changedPreview['changes'] === [['path' => 'config/admin.json', 'type' => 'modified']], 'Preview did not report exact changed configuration metadata.');
196+
$previewMetadata = json_encode($changedPreview['changes'], JSON_THROW_ON_ERROR);
197+
foreach (['fake-user-password', 'fake-api-secret', 'fake-database-password', $key, $signingContents] as $secret) {
198+
backupAssert(!str_contains($previewMetadata, $secret), 'Restore preview change metadata exposed secret material.');
199+
}
191200
$activated = $manager->activateRestore($bundlePath, static fn (): bool => true);
192201
backupAssert($activated['restored'] && JsonFileStore::load($runtimePath . '/admin.json')['server']['adminPort'] !== 8099, 'Atomic restore activation failed.');
193202
$changedAdmin['server']['adminPort'] = 8098;

‎tests/HealthMonitoringTest.php‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ function healthRemove(string $path): void
8383
'sqlParser' => ['running' => false, 'healthy' => false, 'status' => 'stopped', 'pid' => null, 'port' => null],
8484
];
8585
$detail = $monitor->detailed($processes);
86-
healthAssert(isset($detail['status'], $detail['checks']['database'], $detail['checks']['filesystem']) && $tests === 1, 'Detailed health schema or database test is invalid.');
86+
healthAssert(isset($detail['status'], $detail['checks']['database'], $detail['checks']['filesystem'])
87+
&& !isset($detail['checks']['backup']) && $detail['status'] === 'healthy'
88+
&& $tests === 1, 'Detailed health schema, aggregation, or database test is invalid.');
8789
$monitor->detailed($processes);
8890
healthAssert($tests === 1, 'Database health cache did not prevent repeated expensive connectivity work.');
8991
$serialized = json_encode($detail, JSON_THROW_ON_ERROR);
@@ -129,12 +131,14 @@ function healthRemove(string $path): void
129131
$lowDisk = new ApplicationHealthMonitor([
130132
'configurationDirectory' => $configuration, 'databasePath' => $database,
131133
'runtimeDirectory' => $runtime, 'logDirectory' => $logs, 'sessionDirectory' => $sessions,
132-
'backupDirectory' => $backups, 'databaseAvailable' => static fn (): bool => true,
134+
'databaseAvailable' => static fn (): bool => true,
133135
'diskWarningBytes' => 100, 'diskCriticalBytes' => 10, 'diskSpace' => static fn (): int => 50,
134136
]);
135137
healthAssert($lowDisk->detailed($processes)['checks']['filesystem']['status'] === 'degraded', 'Low disk space was not classified as warning.');
136138
@rmdir($backups);
137-
healthAssert($monitor->detailed($processes)['checks']['backup']['category'] === 'unavailable', 'Backup directory capability failure was not detected.');
139+
$withoutBackupDirectory = $monitor->detailed($processes);
140+
healthAssert(!isset($withoutBackupDirectory['checks']['backup'])
141+
&& $withoutBackupDirectory['status'] === 'healthy', 'Backup availability still affects System Health.');
138142

139143
$authFailure = new ApplicationHealthMonitor([
140144
'configurationDirectory' => $configuration, 'databasePath' => $database,

‎tests/UnifiedAdminConsoleTest.php‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,9 +393,25 @@ function (array $database) use (&$connectionTests, &$testedDatabase): void {
393393
&& !str_contains($adminJavaScript, 'webkitdirectory'),
394394
'Admin Backup & Recovery does not use native ZIP selection/download with explicit confirmation.');
395395
foreach (['.backup-summary-grid', '.backup-schedule-grid', '.backup-table-wrap', '.recovery-point-id',
396-
'.restore-file-row', 'dialog.backup-dialog'] as $backupStyle) {
396+
'.recovery-points-scroll', '.backup-change-list', '.restore-file-row', 'dialog.backup-dialog'] as $backupStyle) {
397397
unifiedAdminAssert(str_contains($adminCss, $backupStyle), "Backup & Recovery CSS is missing {$backupStyle}.");
398398
}
399+
unifiedAdminAssert(str_contains($adminJavaScript, 'No Recovery Points')
400+
&& str_contains($adminJavaScript, 'No verified application recovery points are currently available.')
401+
&& str_contains($adminJavaScript, 'tabindex="0" aria-label="Recovery points"')
402+
&& str_contains($adminCss, '--recovery-visible-rows: 10')
403+
&& str_contains($adminCss, 'position: sticky'), 'Recovery points do not provide a bounded, accessible scrolling table or empty state.');
404+
foreach (['No configuration changes detected.', '["added", "modified", "deleted"]', 'backupChangeList(changes)',
405+
'preview.configurationFiles', 'preview.filesChanging', 'preview.changes'] as $previewUi) {
406+
unifiedAdminAssert(str_contains($adminJavaScript, $previewUi), "Restore preview is missing {$previewUi} support.");
407+
}
408+
unifiedAdminAssert(strpos($adminJavaScript, 'doneValidating();') < strpos($adminJavaScript, 'if (!preview) return;'),
409+
'Restore preview validation state is not cleared before the preview dialog opens.');
410+
unifiedAdminAssert(!str_contains($adminJavaScript, '"encryption",\n "backup",')
411+
&& !str_contains((string)file_get_contents(__DIR__ . '/../app/Health/ApplicationHealthMonitor.php'), "'backup' => \$this->backupHealth()"),
412+
'Backup remains in the System Health response or UI aggregation.');
413+
unifiedAdminAssert(str_contains((string)file_get_contents(__DIR__ . '/../app/Health/ApplicationHealthMonitor.php'), 'public function restoreSafety(): array'),
414+
'Restore safety health validation was removed while separating Backup from System Health.');
399415
unifiedAdminAssert(!str_contains(strtolower($adminJavaScript), 'test saved configuration'), 'Removed saved database test remains in the Admin Console.');
400416
unifiedAdminAssert(!str_contains($adminJavaScript, 'Runtime access'), 'Database runtime lifecycle remains under Configuration.');
401417
unifiedAdminAssert(str_contains($adminJavaScript, 'data-database-runtime'), 'Database runtime lifecycle is missing from System Health.');

0 commit comments

Comments
 (0)