fix(web): settings toggles refresh cache, backup next-run from server, update toast once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 22:28:20 -04:00
1 parent 436c463cc8
commit fcd8731a6a
17 files changed
+123 -56

No files matched your search

+13 -2
View File
@@ -20,7 +20,13 @@ import { getSystemHealth } from "@sofa/core/system-health";
import { isTelemetryEnabled } from "@sofa/core/telemetry";
import { getCachedUpdateCheck, isUpdateCheckEnabled } from "@sofa/core/update-check";
import { pauseJobs, rescheduleBackup, resumeJobs, triggerJob as triggerCronJob } from "../../cron";
import {
getJobSchedules,
pauseJobs,
rescheduleBackup,
resumeJobs,
triggerJob as triggerCronJob,
} from "../../cron";
import { os } from "../context";
import { admin } from "../middleware";
@@ -100,12 +106,17 @@ export const backupsRestore = os.admin.backups.restore
});
export const backupsSchedule = os.admin.backups.schedule.use(admin).handler(() => {
const enabled = getSetting("scheduledBackups") === "true";
return {
enabled: getSetting("scheduledBackups") === "true",
enabled,
maxRetention: Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10),
frequency: (getSetting("backupScheduleFrequency") ?? "1d") as "6h" | "12h" | "1d" | "7d",
time: getSetting("backupScheduleTime") ?? "02:00",
dayOfWeek: Number.parseInt(getSetting("backupScheduleDow") ?? "0", 10),
nextRunAt: enabled
? (getJobSchedules().find((j) => j.jobName === "scheduledBackup")?.nextRunAt ?? null)
: null,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
});
@@ -1,6 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconCalendarWeek } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
@@ -38,56 +38,9 @@ interface BackupScheduleState {
dow: number;
}
function getNextBackupDate(frequency: BackupFrequency, time: string, dayOfWeek: number): Date {
const now = new Date();
const [h, m] = time.split(":").map(Number);
if (frequency === "6h") {
const next = new Date(now);
const currentHour = next.getHours();
const nextHour = Math.ceil((currentHour + 1) / 6) * 6;
next.setHours(nextHour, m, 0, 0);
if (next <= now) next.setHours(next.getHours() + 6);
return next;
}
if (frequency === "12h") {
const next = new Date(now);
const h2 = (h + 12) % 24;
const candidates = [h, h2].sort((a, b) => a - b);
for (const candidate of candidates) {
next.setHours(candidate, m, 0, 0);
if (next > now) return next;
}
next.setDate(next.getDate() + 1);
next.setHours(candidates[0], m, 0, 0);
return next;
}
if (frequency === "7d") {
const next = new Date(now);
const daysUntil = (dayOfWeek - next.getDay() + 7) % 7;
if (daysUntil === 0) {
next.setHours(h, m, 0, 0);
if (next > now) return next;
next.setDate(next.getDate() + 7);
next.setHours(h, m, 0, 0);
return next;
}
next.setDate(next.getDate() + daysUntil);
next.setHours(h, m, 0, 0);
return next;
}
// 1d
const next = new Date(now);
next.setHours(h, m, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return next;
}
export function BackupScheduleSection() {
const { t } = useLingui();
const queryClient = useQueryClient();
const DAYS_OF_WEEK = [
t`Sunday`,
@@ -118,9 +71,12 @@ export function BackupScheduleSection() {
const { enabled, maxRetention, frequency, time, dow } = current;
function formatNextBackup(freq: BackupFrequency, timeOfDay: string, dayOfWeek: number): string {
const next = getNextBackupDate(freq, timeOfDay, dayOfWeek);
const distance = formatRelativeTime(next);
const nextRunAt = scheduleData?.nextRunAt;
const timeZone = scheduleData?.timeZone;
function formatNextBackup(): string | null {
if (!nextRunAt) return null;
const distance = formatRelativeTime(new Date(nextRunAt));
return t`Next backup ${distance}`;
}
@@ -151,6 +107,8 @@ export function BackupScheduleSection() {
toast.error(t`Failed to update schedule`);
}
},
onSettled: () =>
queryClient.invalidateQueries({ queryKey: orpc.admin.backups.schedule.key() }),
}),
);
@@ -220,7 +178,8 @@ export function BackupScheduleSection() {
<CardDescription>
{enabled ? (
<span className="inline-flex flex-wrap items-baseline" suppressHydrationWarning>
{formatNextBackup(frequency, time, dow)}. <Trans>Keeping</Trans>{" "}
{formatNextBackup() && <>{formatNextBackup()}. </>}
<Trans>Keeping</Trans>{" "}
<Select
value={String(maxRetention)}
onValueChange={(v) => v && changeMaxRetention(Number(v))}
@@ -331,6 +290,12 @@ export function BackupScheduleSection() {
)}
</AnimatePresence>
{timeZone && (
<p className="text-muted-foreground/70 text-xs">
<Trans>Times use the server's time zone ({timeZone})</Trans>
</p>
)}
{/* Time selector — shown for 12h, 1d, 7d */}
<AnimatePresence initial={false}>
{frequency !== "6h" && (
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, test } from "vitest";
import { formatFieldErrors } from "./fields";
describe("formatFieldErrors", () => {
test("passes strings through", () => {
expect(formatFieldErrors(["Required"])).toBe("Required");
});
test("renders message from issue objects", () => {
expect(formatFieldErrors([{ message: "x", path: ["a"] }])).toBe("x");
});
test("joins mixed arrays with a comma", () => {
expect(formatFieldErrors(["a", { message: "b" }])).toBe("a, b");
});
test("returns empty string for no errors", () => {
expect(formatFieldErrors([])).toBe("");
});
});
+18 -1
View File
@@ -6422,6 +6422,21 @@
"dayOfWeek": {
"type": "number",
"description": "Day of week for weekly backups (0 = Sunday)"
},
"nextRunAt": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "When the next scheduled backup will run (ISO 8601), or null when disabled"
},
"timeZone": {
"type": "string",
"description": "IANA time zone the schedule's times are in (the server's)"
}
},
"required": [
@@ -6429,7 +6444,9 @@
"maxRetention",
"frequency",
"time",
"dayOfWeek"
"dayOfWeek",
"nextRunAt",
"timeZone"
],
"description": "Automated backup schedule configuration"
}
+5
View File
@@ -1062,6 +1062,11 @@ export const BackupScheduleOutput = z
frequency: backupFrequency,
time: z.string().describe("Scheduled time (HH:MM, 24-hour format)"),
dayOfWeek: z.number().describe("Day of week for weekly backups (0 = Sunday)"),
nextRunAt: z
.string()
.nullable()
.describe("When the next scheduled backup will run (ISO 8601), or null when disabled"),
timeZone: z.string().describe("IANA time zone the schedule's times are in (the server's)"),
})
.meta({ description: "Automated backup schedule configuration" });
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "الوقت:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "العنوان"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "Uhrzeit:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "Titel"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr "Time (optional)"
msgid "Time:"
msgstr ""
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr "Times use the server's time zone ({timeZone})"
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "Title"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "Hora:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "Título"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "Heure :"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "Titre"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "שעה:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "כותרת"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "Ora:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "Titolo"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "時刻:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "タイトル"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "시간:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "제목"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "Tijd:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "Titel"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "Hora:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "Título"
+4
View File
@@ -3497,6 +3497,10 @@ msgstr ""
msgid "Time:"
msgstr "时间:"
#: apps/web/src/components/settings/backup-schedule-section.tsx
msgid "Times use the server's time zone ({timeZone})"
msgstr ""
#: apps/web/src/components/command-palette.tsx
msgid "Title"
msgstr "标题"