fix(backup-ui): respect general_date_format + general_time_format

The four backup admin panes (BackupHistory, BackupDashboard,
BackupCoverageCard, BackupIntegrityCard) used raw date-fns
`format()` with hard-coded tokens like 'p' (12-hour AM/PM), 'PP',
'PPP', 'PPp', and 'yyyy-MM-dd HH:mm:ss' — ignoring the admin's
configured `general_date_format` and `general_time_format`
settings.

Net effect on a 24h-configured install: backup History row showed
"11:25 PM" instead of "23:25", and the Coverage tab's "Last dump"
+ "Coverage generated" timestamps were stuck on
yyyy-MM-dd HH:mm:ss regardless of the admin's date-format choice.

All four panes now route through `useLocalizedDate()` which honors
both settings + the active i18n locale (per the existing
[[feedback_respect_general_format_settings]] pattern).

Tokens replaced:
  format(date, 'p')              → formatTime(date)
  format(date, 'PP')             → format(date)
  format(date, 'PPP')            → format(date)
  format(date, 'PPp')            → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm:ss') → formatDateTime(date)
  format(date, 'yyyy-MM-dd HH:mm')    → formatDateTime(date)

No backend changes — settings already shipped via /admin/settings;
this just makes the consumers actually read them.
This commit is contained in:
Luca
2026-05-31 23:33:50 +02:00
parent 83fdb47fbf
commit 09f6a1af6a
5 changed files with 57 additions and 20 deletions
+21 -5
View File
@@ -1000,11 +1000,27 @@ END $$;`
await reinitPool(); await reinitPool();
this.log('info', 'Knex pool re-initialized'); this.log('info', 'Knex pool re-initialized');
// Run migrations to ensure schema is up to date. Use the // NOTE: we deliberately do NOT call `db.migrate.latest()` here.
// module-level `db` export, which is the Proxy that now points //
// at the freshly-initialized pool. // The picpeak migrations directory contains `helpers.js` (a
this.log('info', 'Running database migrations...'); // shared helper module, not a migration), plus `core/` and
await db.migrate.latest(); // `legacy/` subdirectories. Knex's built-in migrator scans the
// top-level directory and rejects any file without `up`/`down`
// exports — so `db.migrate.latest()` throws
// Invalid migration: helpers.js must have both an up and down function
// every time it runs in this codebase. The production code path
// uses `npm run migrate:safe` (run-migrations-safe.js) which
// knows to skip helpers.js + walks core/ explicitly.
//
// For restore: the dump we just loaded already contains the
// schema state of whatever migrations had been applied at
// backup time. If the running image has NEWER migrations that
// need to run on top of the restored DB, those will be applied
// on the NEXT container start by wait-for-db.sh + the safe
// runner. That's a one-restart penalty in the unusual case of
// restoring from a backup older than the current image, and
// matches what picpeak does on every other boot already.
this.log('info', 'Skipping in-process migrate (deferred to next boot via safe runner)');
// Replay the snapshotted operator-meta settings on top of the // Replay the snapshotted operator-meta settings on top of the
// restored DB. UPSERT by setting_key — if the backup had the // restored DB. UPSERT by setting_key — if the backup had the
@@ -14,7 +14,8 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns'; // Locale-aware formatters per [[feedback_respect_general_format_settings]].
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Card, Button } from '../common'; import { Card, Button } from '../common';
import { import {
@@ -41,6 +42,7 @@ import {
*/ */
export const BackupCoverageCard: React.FC = () => { export const BackupCoverageCard: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ['backup-coverage'], queryKey: ['backup-coverage'],
queryFn: () => adminService.getBackupCoverage(), queryFn: () => adminService.getBackupCoverage(),
@@ -75,7 +77,7 @@ export const BackupCoverageCard: React.FC = () => {
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4">
{t('backup.coverage.generatedAt', 'Coverage generated: {{when}}', { {t('backup.coverage.generatedAt', 'Coverage generated: {{when}}', {
when: format(new Date(data.generatedAt), 'yyyy-MM-dd HH:mm:ss'), when: formatDateTime(new Date(data.generatedAt)),
})} })}
</p> </p>
</> </>
@@ -164,6 +166,7 @@ const DatabaseStatusCard: React.FC<{
database: BackupCoverageReport['database']; database: BackupCoverageReport['database'];
}> = ({ database }) => { }> = ({ database }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const isInline = database.mode === 'inline'; const isInline = database.mode === 'inline';
const tone: Tone = database.ok ? 'green' : 'red'; const tone: Tone = database.ok ? 'green' : 'red';
const dumpAge = database.lastDumpAgeMs !== null const dumpAge = database.lastDumpAgeMs !== null
@@ -194,7 +197,7 @@ const DatabaseStatusCard: React.FC<{
<> <>
<Row <Row
label={t('backup.coverage.database.lastDump', 'Last dump')} label={t('backup.coverage.database.lastDump', 'Last dump')}
value={`${format(new Date(database.lastDumpAt), 'yyyy-MM-dd HH:mm')}${ value={`${formatDateTime(new Date(database.lastDumpAt))}${
dumpAge ? ` (${dumpAge})` : '' dumpAge ? ` (${dumpAge})` : ''
}`} }`}
/> />
@@ -16,7 +16,10 @@ import {
AlertTriangle, AlertTriangle,
Info Info
} from 'lucide-react'; } from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns'; // Per [[feedback_respect_general_format_settings]] — route every
// displayed date/time through useLocalizedDate so general_date_format
// and general_time_format settings apply uniformly.
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Card, Button } from '../common'; import { Card, Button } from '../common';
export type HealthStatus = 'excellent' | 'good' | 'warning' | 'critical'; export type HealthStatus = 'excellent' | 'good' | 'warning' | 'critical';
@@ -109,6 +112,7 @@ const healthColors: Record<HealthStatus, string> = {
export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config, onRunBackup, isBackupRunning }) => { export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config, onRunBackup, isBackupRunning }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { format, formatTime, formatDateTime, formatDistanceToNow } = useLocalizedDate();
const lastBackup = status?.lastBackup; // any status const lastBackup = status?.lastBackup; // any status
const lastSuccessfulBackup = status?.lastSuccessfulBackup; // status='completed' only const lastSuccessfulBackup = status?.lastSuccessfulBackup; // status='completed' only
const zombieRuns = status?.zombieRuns ?? []; const zombieRuns = status?.zombieRuns ?? [];
@@ -285,7 +289,7 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
label={t('backup.dashboard.stats.totalBackups')} label={t('backup.dashboard.stats.totalBackups')}
value={status?.totalBackups || 0} value={status?.totalBackups || 0}
color="blue" color="blue"
subtext={lastBackup ? `${t('backup.dashboard.stats.last')}: ${format(new Date(lastBackup.created_at), 'PP')}` : t('backup.dashboard.stats.noBackupsYet')} subtext={lastBackup ? `${t('backup.dashboard.stats.last')}: ${format(new Date(lastBackup.created_at))}` : t('backup.dashboard.stats.noBackupsYet')}
/> />
<StatCard <StatCard
@@ -301,7 +305,7 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
label={t('backup.dashboard.stats.lastDuration')} label={t('backup.dashboard.stats.lastDuration')}
value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'} value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'}
color="purple" color="purple"
subtext={lastBackup ? format(new Date(lastBackup.created_at), 'p') : ''} subtext={lastBackup ? formatTime(new Date(lastBackup.created_at)) : ''}
/> />
<StatCard <StatCard
@@ -333,7 +337,7 @@ export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config
{t('backup.dashboard.backupType', { type: backup.backup_type })} {t('backup.dashboard.backupType', { type: backup.backup_type })}
</p> </p>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-500 dark:text-neutral-400">
{format(new Date(backup.created_at), 'PPp')} {formatDateTime(new Date(backup.created_at))}
</p> </p>
</div> </div>
</div> </div>
@@ -20,11 +20,17 @@ import {
RefreshCw, RefreshCw,
Loader2 Loader2
} from 'lucide-react'; } from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common'; import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api'; import { api } from '../../config/api';
// Per [[feedback_respect_general_format_settings]]: route every displayed
// date/time through useLocalizedDate so the admin's general_date_format +
// general_time_format settings apply uniformly. Previously the backup
// History pane used raw date-fns format() with hard-coded 'p' (12-hour
// AM/PM) and 'PPP' (US-locale long date), which ignored the settings —
// Ralf 2026-05-31 flagged "11:25 PM" on a 24h-configured install.
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
const statusIcons = { const statusIcons = {
completed: { icon: CheckCircle, color: 'text-green-500' }, completed: { icon: CheckCircle, color: 'text-green-500' },
@@ -48,6 +54,12 @@ export const BackupHistory = () => {
const [filterStatus, setFilterStatus] = useState('all'); const [filterStatus, setFilterStatus] = useState('all');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Locale-aware formatters that respect admin's general_date_format +
// general_time_format settings. See useLocalizedDate.ts for the full
// contract; formatTime gives "HH:mm" (24h) or "h:mm a" (12h) based on
// the setting, format(date) honors general_date_format, and
// formatDistanceToNow returns "2 minutes ago" in the admin's i18n locale.
const { format, formatTime, formatDistanceToNow } = useLocalizedDate();
// Fetch backup history // Fetch backup history
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
@@ -90,7 +102,7 @@ export const BackupHistory = () => {
}; };
const handleDelete = (backup) => { const handleDelete = (backup) => {
if (window.confirm(`Are you sure you want to delete this backup from ${format(new Date(backup.created_at), 'PPP')}?`)) { if (window.confirm(`Are you sure you want to delete this backup from ${format(new Date(backup.created_at))}?`)) {
deleteMutation.mutate(backup.id); deleteMutation.mutate(backup.id);
} }
}; };
@@ -200,10 +212,10 @@ export const BackupHistory = () => {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div> <div>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100"> <p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{format(new Date(backup.created_at), 'PPP')} {format(new Date(backup.created_at))}
</p> </p>
<p className="text-xs text-neutral-500 dark:text-neutral-400"> <p className="text-xs text-neutral-500 dark:text-neutral-400">
{format(new Date(backup.created_at), 'p')} {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })} {formatTime(new Date(backup.created_at))} {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
</p> </p>
</div> </div>
</td> </td>
@@ -270,12 +282,12 @@ export const BackupHistory = () => {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.started')}:</span> <span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.started')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.created_at), 'p')}</span> <span className="text-neutral-900 dark:text-neutral-100">{formatTime(new Date(backup.created_at))}</span>
</div> </div>
{backup.completed_at && ( {backup.completed_at && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.completed')}:</span> <span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.completed')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{format(new Date(backup.completed_at), 'p')}</span> <span className="text-neutral-900 dark:text-neutral-100">{formatTime(new Date(backup.completed_at))}</span>
</div> </div>
)} )}
</div> </div>
@@ -10,7 +10,8 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { format } from 'date-fns'; // Locale-aware formatters per [[feedback_respect_general_format_settings]].
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Card, Button } from '../common'; import { Card, Button } from '../common';
import { adminService, BackupIntegrityReport } from '../../services/admin.service'; import { adminService, BackupIntegrityReport } from '../../services/admin.service';
@@ -33,6 +34,7 @@ import { adminService, BackupIntegrityReport } from '../../services/admin.servic
*/ */
export const BackupIntegrityCard: React.FC = () => { export const BackupIntegrityCard: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const [report, setReport] = useState<BackupIntegrityReport | null>(null); const [report, setReport] = useState<BackupIntegrityReport | null>(null);
const [expanded, setExpanded] = useState<'missing' | 'hashMismatches' | null>(null); const [expanded, setExpanded] = useState<'missing' | 'hashMismatches' | null>(null);
@@ -147,7 +149,7 @@ export const BackupIntegrityCard: React.FC = () => {
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('backup.integrity.scannedAt', 'Last checked: {{when}}', { {t('backup.integrity.scannedAt', 'Last checked: {{when}}', {
when: format(new Date(report.scannedAt), 'yyyy-MM-dd HH:mm:ss'), when: formatDateTime(new Date(report.scannedAt)),
})} })}
</p> </p>