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

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.
maziggy 3 недель назад
Родитель
Сommit
2953b2bf81

+ 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>
                   <thead>
                     <tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
                     <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.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.status')}</th>
                       <th className="text-left py-2 px-2">{t('backup.commit')}</th>
                       <th className="text-left py-2 px-2">{t('backup.commit')}</th>
                     </tr>
                     </tr>
@@ -1040,6 +1041,12 @@ export function GitHubBackupSettings() {
                     {logs.slice(0, 10).map((log) => (
                     {logs.slice(0, 10).map((log) => (
                       <tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
                       <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>
                         <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"><StatusBadge status={log.status} /></td>
                         <td className="py-2 px-2">
                         <td className="py-2 px-2">
                           {log.commit_sha ? (
                           {log.commit_sha ? (

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -4927,6 +4927,12 @@ export default {
     clear: '清除',
     clear: '清除',
     date: '日期',
     date: '日期',
     status: '狀態',
     status: '狀態',
+    trigger: '類型',
+    triggers: {
+      manual: '備份(手動)',
+      scheduled: '備份(排程)',
+      restore: '還原',
+    },
     commit: '提交',
     commit: '提交',
 
 
     // Local Backup
     // 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 -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <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>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

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