Просмотр исходного кода

fix(backup): tell a restore apart from a backup in Backup History (#2656)

A restore writes a `github_backup_logs` row too — same table, same status
values, and it already carried `trigger: 'restore'`, which the API already
returned. The history table rendered date / status / commit only, so the row
read as a successful backup dated now while "Last backup" said something
else: `last_backup_at` is only stamped by an actual backup, and the two
disagreeing is alarming with nothing on screen to explain it.

Adds the Type column the trigger was always there to fill. Unknown values
fall back to the raw string rather than rendering blank, matching the
`backup.pathCheck.*` lookup a few hundred lines up — a trigger kind added
later shows up as itself instead of vanishing.

Backend unchanged: it has recorded this correctly since the restore path was
written.

Three tests, all three failing without the column. 13 locales in parity at
5776 leaves — pt-BR takes "Backup manual" rather than the parenthesised form
because "Backup (manual)" is identical to en, which the parity check counts
as untranslated.

Bundle rebuilt: `index-DhOfNgMz.js` → `index-CadgB7UN.js`. It also picks up
the `archivesOwnerUnknown` leaves from the previous commit, which changed
i18n without rebuilding.
jmoore-skild 1 месяц назад
Родитель
Сommit
0cf5b8da41

+ 97 - 0
frontend/src/__tests__/components/GitHubBackupSettings.history.test.tsx

@@ -0,0 +1,97 @@
+/**
+ * Backup History must distinguish a restore from a backup (#2656).
+ *
+ * A restore writes a `github_backup_logs` row too — same table, same statuses,
+ * `trigger: 'restore'`. The table rendered date / status / commit only, so the
+ * row read as a successful backup dated now, while "Last backup" said something
+ * else entirely. The column below is the only thing telling the two apart.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, within } from '@testing-library/react';
+import { render } from '../utils';
+import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
+import { api } from '../../api/client';
+
+vi.mock('../../api/client', () => ({
+  api: {
+    getGitHubBackupConfig: vi.fn().mockResolvedValue({
+      id: 1,
+      repository_url: 'https://github.com/someone/backup',
+      enabled: true,
+    }),
+    getGitHubBackupStatus: vi.fn().mockResolvedValue({ is_running: false, configured: true, enabled: true }),
+    getGitHubBackupLogs: vi.fn(),
+    getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
+    getPrinters: vi.fn().mockResolvedValue([]),
+    getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+    getLocalBackups: vi.fn().mockResolvedValue([]),
+    getLocalBackupStatus: vi.fn().mockResolvedValue({
+      enabled: false,
+      is_running: false,
+      last_backup_at: null,
+      last_status: null,
+      last_message: null,
+      next_run: null,
+    }),
+    checkLocalBackupPath: vi.fn().mockResolvedValue({ writable: true, path: '/data', code: 'ok' }),
+  },
+}));
+
+const log = (id: number, trigger: string) => ({
+  id,
+  config_id: 1,
+  started_at: '2026-08-02T09:00:00',
+  completed_at: '2026-08-02T09:00:05',
+  status: 'success',
+  trigger,
+  commit_sha: null,
+  files_changed: 0,
+  error_message: null,
+});
+
+const historyRows = async () => {
+  const table = (await screen.findByText('History')).closest('div[id="card-backup-history"]');
+  return within(table as HTMLElement).getAllByRole('row').slice(1); // drop the header
+};
+
+describe('GitHubBackupSettings — backup history', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('labels a restore as a restore, not a successful backup', async () => {
+    vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'restore')]);
+
+    render(<GitHubBackupSettings />);
+
+    const [row] = await historyRows();
+    expect(within(row).getByText('Restore')).toBeInTheDocument();
+  });
+
+  it('tells the three trigger kinds apart in one history', async () => {
+    vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([
+      log(1, 'restore'),
+      log(2, 'scheduled'),
+      log(3, 'manual'),
+    ]);
+
+    render(<GitHubBackupSettings />);
+
+    const rows = await historyRows();
+    expect(within(rows[0]).getByText('Restore')).toBeInTheDocument();
+    expect(within(rows[1]).getByText('Backup (scheduled)')).toBeInTheDocument();
+    expect(within(rows[2]).getByText('Backup (manual)')).toBeInTheDocument();
+  });
+
+  it('falls back to the raw trigger rather than blanking an unknown one', async () => {
+    vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'something-new')]);
+
+    render(<GitHubBackupSettings />);
+
+    const [row] = await historyRows();
+    expect(within(row).getByText('something-new')).toBeInTheDocument();
+  });
+});

+ 7 - 0
frontend/src/components/GitHubBackupSettings.tsx

@@ -1032,6 +1032,7 @@ export function GitHubBackupSettings() {
                   <thead>
                     <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
                       <th className="text-left py-2 px-2">{t('backup.date')}</th>
+                      <th className="text-left py-2 px-2">{t('backup.trigger')}</th>
                       <th className="text-left py-2 px-2">{t('backup.status')}</th>
                       <th className="text-left py-2 px-2">{t('backup.commit')}</th>
                     </tr>
@@ -1040,6 +1041,12 @@ export function GitHubBackupSettings() {
                     {logs.slice(0, 10).map((log) => (
                       <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
                         <td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
+                        {/* A restore writes a log row too, and without this it
+                            was indistinguishable from a backup: a successful
+                            run dated now, while Last backup said otherwise. */}
+                        <td className="py-2 px-2 text-bambu-gray">
+                          {t(`backup.triggers.${log.trigger}`, { defaultValue: log.trigger })}
+                        </td>
                         <td className="py-2 px-2"><StatusBadge status={log.status} /></td>
                         <td className="py-2 px-2">
                           {log.commit_sha ? (

+ 6 - 0
frontend/src/i18n/locales/de.ts

@@ -4939,6 +4939,12 @@ export default {
     clear: 'Löschen',
     date: 'Datum',
     status: 'Status',
+    trigger: 'Typ',
+    triggers: {
+      manual: 'Sicherung (manuell)',
+      scheduled: 'Sicherung (geplant)',
+      restore: 'Wiederherstellung',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/en.ts

@@ -4987,6 +4987,12 @@ export default {
     clear: 'Clear',
     date: 'Date',
     status: 'Status',
+    trigger: 'Type',
+    triggers: {
+      manual: 'Backup (manual)',
+      scheduled: 'Backup (scheduled)',
+      restore: 'Restore',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/es.ts

@@ -4947,6 +4947,12 @@ export default {
     clear: 'Borrar',
     date: 'Fecha',
     status: 'Estado',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Copia (manual)',
+      scheduled: 'Copia (programada)',
+      restore: 'Restauración',
+    },
     commit: 'Confirmación',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/fr.ts

@@ -4928,6 +4928,12 @@ export default {
     clear: 'Effacer',
     date: 'Date',
     status: 'Statut',
+    trigger: 'Type',
+    triggers: {
+      manual: 'Sauvegarde (manuelle)',
+      scheduled: 'Sauvegarde (planifiée)',
+      restore: 'Restauration',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/it.ts

@@ -4927,6 +4927,12 @@ export default {
     clear: 'Cancella',
     date: 'Data',
     status: 'Stato',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Backup (manuale)',
+      scheduled: 'Backup (pianificato)',
+      restore: 'Ripristino',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/ja.ts

@@ -4939,6 +4939,12 @@ export default {
     clear: 'クリア',
     date: '日付',
     status: 'ステータス',
+    trigger: '種類',
+    triggers: {
+      manual: 'バックアップ(手動)',
+      scheduled: 'バックアップ(スケジュール)',
+      restore: '復元',
+    },
     commit: 'コミット',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/ko.ts

@@ -4702,6 +4702,12 @@ export default {
     clear: '초기화',
     date: '날짜',
     status: '상태',
+    trigger: '유형',
+    triggers: {
+      manual: '백업(수동)',
+      scheduled: '백업(예약)',
+      restore: '복원',
+    },
     commit: '커밋',
     localBackup: '로컬 백업',
     localBackupDescription: '데이터베이스, 아카이브, 업로드 및 모든 파일을 포함한 Bambuddy 데이터의 전체 백업을 만듭니다.',

+ 6 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4927,6 +4927,12 @@ export default {
     clear: 'Limpar',
     date: 'Data',
     status: 'Status',
+    trigger: 'Tipo',
+    triggers: {
+      manual: 'Backup manual',
+      scheduled: 'Backup agendado',
+      restore: 'Restauração',
+    },
     commit: 'Commit',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/ru.ts

@@ -4694,6 +4694,12 @@ export default {
     clear: "Очистить",
     date: "Дата",
     status: "Статус",
+    trigger: "Тип",
+    triggers: {
+      manual: "Резервная копия (вручную)",
+      scheduled: "Резервная копия (по расписанию)",
+      restore: "Восстановление",
+    },
     commit: "Коммит",
     localBackup: "Локальная резервная копия",
     localBackupDescription: "Создать полную резервную копию данных Bambuddy, включая базу данных, архивы, загрузки и все файлы.",

+ 6 - 0
frontend/src/i18n/locales/tr.ts

@@ -4916,6 +4916,12 @@ export default {
     clear: 'Temizle',
     date: 'Tarih',
     status: 'Durum',
+    trigger: 'Tür',
+    triggers: {
+      manual: 'Yedek (manuel)',
+      scheduled: 'Yedek (zamanlanmış)',
+      restore: 'Geri yükleme',
+    },
     commit: 'Commit',
 
     // Yerel Yedekleme

+ 6 - 0
frontend/src/i18n/locales/uk.ts

@@ -4982,6 +4982,12 @@ export default {
     clear: "Очистити",
     date: "Дата",
     status: "Статус",
+    trigger: "Тип",
+    triggers: {
+      manual: "Резервна копія (вручну)",
+      scheduled: "Резервна копія (за розкладом)",
+      restore: "Відновлення",
+    },
     commit: "Коміт",
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4927,6 +4927,12 @@ export default {
     clear: '清除',
     date: '日期',
     status: '状态',
+    trigger: '类型',
+    triggers: {
+      manual: '备份(手动)',
+      scheduled: '备份(计划)',
+      restore: '恢复',
+    },
     commit: '提交',
 
     // Local Backup

+ 6 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4927,6 +4927,12 @@ export default {
     clear: '清除',
     date: '日期',
     status: '狀態',
+    trigger: '類型',
+    triggers: {
+      manual: '備份(手動)',
+      scheduled: '備份(排程)',
+      restore: '還原',
+    },
     commit: '提交',
 
     // Local Backup

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-B3jj6-fz.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-BkwFYHam.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-C_6BSgrK.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DhOfNgMz.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-C_6BSgrK.css">
+    <script type="module" crossorigin src="/assets/index-BkwFYHam.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B3jj6-fz.css">
   </head>
   <body>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов