mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 03:30:39 +09:00
feat(backups): gzip compress backups and add retention config to Instance Settings
Compress database backups with gzip (.sql.gz), reducing file size ~83%. Add backup retention configuration to Instance Settings UI with preset options (7 days, 2 weeks, 1 month). The backup scheduler now reads retention from the database on each tick so changes take effect without restart. Default retention changed from 30 to 7 days.
This commit is contained in:
parent
316790ea0a
commit
cc44d309c0
11 changed files with 107 additions and 17 deletions
|
|
@ -129,8 +129,8 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
|
||||||
filenamePrefix: "paperclip-test",
|
filenamePrefix: "paperclip-test",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/);
|
expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/);
|
||||||
expect(result.sizeBytes).toBeGreaterThan(1024 * 1024);
|
expect(result.sizeBytes).toBeGreaterThan(0);
|
||||||
expect(fs.existsSync(result.backupFile)).toBe(true);
|
expect(fs.existsSync(result.backupFile)).toBe(true);
|
||||||
|
|
||||||
await runDatabaseRestore({
|
await runDatabaseRestore({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||||
import { basename, resolve } from "node:path";
|
import { basename, resolve } from "node:path";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { createGunzip, createGzip } from "node:zlib";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
|
||||||
export type RunDatabaseBackupOptions = {
|
export type RunDatabaseBackupOptions = {
|
||||||
|
|
@ -82,7 +84,8 @@ function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefi
|
||||||
let pruned = 0;
|
let pruned = 0;
|
||||||
|
|
||||||
for (const name of readdirSync(backupDir)) {
|
for (const name of readdirSync(backupDir)) {
|
||||||
if (!name.startsWith(`${filenamePrefix}-`) || !name.endsWith(".sql")) continue;
|
if (!name.startsWith(`${filenamePrefix}-`)) continue;
|
||||||
|
if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue;
|
||||||
const fullPath = resolve(backupDir, name);
|
const fullPath = resolve(backupDir, name);
|
||||||
const stat = statSync(fullPath);
|
const stat = statSync(fullPath);
|
||||||
if (stat.mtimeMs < cutoff) {
|
if (stat.mtimeMs < cutoff) {
|
||||||
|
|
@ -148,7 +151,9 @@ function tableKey(schemaName: string, tableName: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
|
async function* readRestoreStatements(backupFile: string): AsyncGenerator<string> {
|
||||||
const stream = createReadStream(backupFile, { encoding: "utf8" });
|
const raw = createReadStream(backupFile);
|
||||||
|
const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw;
|
||||||
|
stream.setEncoding("utf8");
|
||||||
const reader = createInterface({
|
const reader = createInterface({
|
||||||
input: stream,
|
input: stream,
|
||||||
crlfDelay: Infinity,
|
crlfDelay: Infinity,
|
||||||
|
|
@ -180,6 +185,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator<string
|
||||||
} finally {
|
} finally {
|
||||||
reader.close();
|
reader.close();
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
|
raw.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,8 +294,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||||
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns);
|
||||||
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout });
|
||||||
mkdirSync(opts.backupDir, { recursive: true });
|
mkdirSync(opts.backupDir, { recursive: true });
|
||||||
const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`);
|
||||||
const writer = createBufferedTextFileWriter(backupFile);
|
const backupFile = `${sqlFile}.gz`;
|
||||||
|
const writer = createBufferedTextFileWriter(sqlFile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sql`SELECT 1`;
|
await sql`SELECT 1`;
|
||||||
|
|
@ -664,6 +671,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||||
|
|
||||||
await writer.close();
|
await writer.close();
|
||||||
|
|
||||||
|
// Compress the SQL file with gzip
|
||||||
|
const sqlReadStream = createReadStream(sqlFile);
|
||||||
|
const gzWriteStream = createWriteStream(backupFile);
|
||||||
|
await pipeline(sqlReadStream, createGzip(), gzWriteStream);
|
||||||
|
unlinkSync(sqlFile);
|
||||||
|
|
||||||
const sizeBytes = statSync(backupFile).size;
|
const sizeBytes = statSync(backupFile).size;
|
||||||
const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix);
|
const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix);
|
||||||
|
|
||||||
|
|
@ -674,6 +687,9 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await writer.abort();
|
await writer.abort();
|
||||||
|
if (existsSync(backupFile)) {
|
||||||
|
try { unlinkSync(backupFile); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await sql.end();
|
await sql.end();
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export const llmConfigSchema = z.object({
|
||||||
export const databaseBackupConfigSchema = z.object({
|
export const databaseBackupConfigSchema = z.object({
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60),
|
intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60),
|
||||||
retentionDays: z.number().int().min(1).max(3650).default(30),
|
retentionDays: z.number().int().min(1).max(3650).default(7),
|
||||||
dir: z.string().default("~/.paperclip/instances/default/data/backups"),
|
dir: z.string().default("~/.paperclip/instances/default/data/backups"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -33,7 +33,7 @@ export const databaseConfigSchema = z.object({
|
||||||
backup: databaseBackupConfigSchema.default({
|
backup: databaseBackupConfigSchema.default({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
intervalMinutes: 60,
|
intervalMinutes: 60,
|
||||||
retentionDays: 30,
|
retentionDays: 7,
|
||||||
dir: "~/.paperclip/instances/default/data/backups",
|
dir: "~/.paperclip/instances/default/data/backups",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,7 @@ export type {
|
||||||
InstanceExperimentalSettings,
|
InstanceExperimentalSettings,
|
||||||
InstanceGeneralSettings,
|
InstanceGeneralSettings,
|
||||||
InstanceSettings,
|
InstanceSettings,
|
||||||
|
BackupRetentionDays,
|
||||||
Agent,
|
Agent,
|
||||||
AgentAccessState,
|
AgentAccessState,
|
||||||
AgentChainOfCommandEntry,
|
AgentChainOfCommandEntry,
|
||||||
|
|
@ -369,6 +370,11 @@ export {
|
||||||
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION,
|
||||||
} from "./types/feedback.js";
|
} from "./types/feedback.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
BACKUP_RETENTION_PRESETS,
|
||||||
|
DEFAULT_BACKUP_RETENTION_DAYS,
|
||||||
|
} from "./types/instance.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getClosedIsolatedExecutionWorkspaceMessage,
|
getClosedIsolatedExecutionWorkspaceMessage,
|
||||||
isClosedIsolatedExecutionWorkspace,
|
isClosedIsolatedExecutionWorkspace,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ export type {
|
||||||
FeedbackTraceBundleFile,
|
FeedbackTraceBundleFile,
|
||||||
FeedbackTraceBundle,
|
FeedbackTraceBundle,
|
||||||
} from "./feedback.js";
|
} from "./feedback.js";
|
||||||
export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } from "./instance.js";
|
export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionDays } from "./instance.js";
|
||||||
|
export { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "./instance.js";
|
||||||
export type {
|
export type {
|
||||||
CompanySkillSourceType,
|
CompanySkillSourceType,
|
||||||
CompanySkillTrustLevel,
|
CompanySkillTrustLevel,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import type { FeedbackDataSharingPreference } from "./feedback.js";
|
import type { FeedbackDataSharingPreference } from "./feedback.js";
|
||||||
|
|
||||||
|
export const BACKUP_RETENTION_PRESETS = [7, 14, 30] as const;
|
||||||
|
export type BackupRetentionDays = (typeof BACKUP_RETENTION_PRESETS)[number];
|
||||||
|
export const DEFAULT_BACKUP_RETENTION_DAYS: BackupRetentionDays = 7;
|
||||||
|
|
||||||
export interface InstanceGeneralSettings {
|
export interface InstanceGeneralSettings {
|
||||||
censorUsernameInLogs: boolean;
|
censorUsernameInLogs: boolean;
|
||||||
keyboardShortcuts: boolean;
|
keyboardShortcuts: boolean;
|
||||||
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
feedbackDataSharingPreference: FeedbackDataSharingPreference;
|
||||||
|
backupRetentionDays: BackupRetentionDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstanceExperimentalSettings {
|
export interface InstanceExperimentalSettings {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js";
|
import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js";
|
||||||
|
import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "../types/instance.js";
|
||||||
import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
|
import { feedbackDataSharingPreferenceSchema } from "./feedback.js";
|
||||||
|
|
||||||
|
export const backupRetentionDaysSchema = z.number().refine(
|
||||||
|
(v): v is (typeof BACKUP_RETENTION_PRESETS)[number] =>
|
||||||
|
(BACKUP_RETENTION_PRESETS as readonly number[]).includes(v),
|
||||||
|
{ message: `Must be one of: ${BACKUP_RETENTION_PRESETS.join(", ")}` },
|
||||||
|
);
|
||||||
|
|
||||||
export const instanceGeneralSettingsSchema = z.object({
|
export const instanceGeneralSettingsSchema = z.object({
|
||||||
censorUsernameInLogs: z.boolean().default(false),
|
censorUsernameInLogs: z.boolean().default(false),
|
||||||
keyboardShortcuts: z.boolean().default(false),
|
keyboardShortcuts: z.boolean().default(false),
|
||||||
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
|
feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default(
|
||||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||||
),
|
),
|
||||||
|
backupRetentionDays: backupRetentionDaysSchema.default(DEFAULT_BACKUP_RETENTION_DAYS),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
|
export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial();
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@ export function loadConfig(): Config {
|
||||||
1,
|
1,
|
||||||
Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ||
|
Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) ||
|
||||||
fileDatabaseBackup?.retentionDays ||
|
fileDatabaseBackup?.retentionDays ||
|
||||||
30,
|
7,
|
||||||
);
|
);
|
||||||
const databaseBackupDir = resolveHomeAwarePath(
|
const databaseBackupDir = resolveHomeAwarePath(
|
||||||
process.env.PAPERCLIP_DB_BACKUP_DIR ??
|
process.env.PAPERCLIP_DB_BACKUP_DIR ??
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js";
|
||||||
import {
|
import {
|
||||||
feedbackService,
|
feedbackService,
|
||||||
heartbeatService,
|
heartbeatService,
|
||||||
|
instanceSettingsService,
|
||||||
reconcilePersistedRuntimeServicesOnStartup,
|
reconcilePersistedRuntimeServicesOnStartup,
|
||||||
routineService,
|
routineService,
|
||||||
} from "./services/index.js";
|
} from "./services/index.js";
|
||||||
|
|
@ -628,20 +629,25 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
|
|
||||||
if (config.databaseBackupEnabled) {
|
if (config.databaseBackupEnabled) {
|
||||||
const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
|
const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000;
|
||||||
|
const settingsSvc = instanceSettingsService(db);
|
||||||
let backupInFlight = false;
|
let backupInFlight = false;
|
||||||
|
|
||||||
const runScheduledBackup = async () => {
|
const runScheduledBackup = async () => {
|
||||||
if (backupInFlight) {
|
if (backupInFlight) {
|
||||||
logger.warn("Skipping scheduled database backup because a previous backup is still running");
|
logger.warn("Skipping scheduled database backup because a previous backup is still running");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
backupInFlight = true;
|
backupInFlight = true;
|
||||||
try {
|
try {
|
||||||
|
// Read retention from Instance Settings (DB) so changes take effect without restart
|
||||||
|
const generalSettings = await settingsSvc.getGeneral();
|
||||||
|
const retentionDays = generalSettings.backupRetentionDays;
|
||||||
|
|
||||||
const result = await runDatabaseBackup({
|
const result = await runDatabaseBackup({
|
||||||
connectionString: activeDatabaseConnectionString,
|
connectionString: activeDatabaseConnectionString,
|
||||||
backupDir: config.databaseBackupDir,
|
backupDir: config.databaseBackupDir,
|
||||||
retentionDays: config.databaseBackupRetentionDays,
|
retentionDays,
|
||||||
filenamePrefix: "paperclip",
|
filenamePrefix: "paperclip",
|
||||||
});
|
});
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -650,7 +656,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
sizeBytes: result.sizeBytes,
|
sizeBytes: result.sizeBytes,
|
||||||
prunedCount: result.prunedCount,
|
prunedCount: result.prunedCount,
|
||||||
backupDir: config.databaseBackupDir,
|
backupDir: config.databaseBackupDir,
|
||||||
retentionDays: config.databaseBackupRetentionDays,
|
retentionDays,
|
||||||
},
|
},
|
||||||
`Automatic database backup complete: ${formatDatabaseBackupResult(result)}`,
|
`Automatic database backup complete: ${formatDatabaseBackupResult(result)}`,
|
||||||
);
|
);
|
||||||
|
|
@ -660,7 +666,7 @@ export async function startServer(): Promise<StartedServer> {
|
||||||
backupInFlight = false;
|
backupInFlight = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
intervalMinutes: config.databaseBackupIntervalMinutes,
|
intervalMinutes: config.databaseBackupIntervalMinutes,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Db } from "@paperclipai/db";
|
||||||
import { companies, instanceSettings } from "@paperclipai/db";
|
import { companies, instanceSettings } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||||
|
DEFAULT_BACKUP_RETENTION_DAYS,
|
||||||
instanceGeneralSettingsSchema,
|
instanceGeneralSettingsSchema,
|
||||||
type InstanceGeneralSettings,
|
type InstanceGeneralSettings,
|
||||||
instanceExperimentalSettingsSchema,
|
instanceExperimentalSettingsSchema,
|
||||||
|
|
@ -22,12 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
|
||||||
keyboardShortcuts: parsed.data.keyboardShortcuts ?? false,
|
keyboardShortcuts: parsed.data.keyboardShortcuts ?? false,
|
||||||
feedbackDataSharingPreference:
|
feedbackDataSharingPreference:
|
||||||
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||||
|
backupRetentionDays: parsed.data.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
censorUsernameInLogs: false,
|
censorUsernameInLogs: false,
|
||||||
keyboardShortcuts: false,
|
keyboardShortcuts: false,
|
||||||
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE,
|
||||||
|
backupRetentionDays: DEFAULT_BACKUP_RETENTION_DAYS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
|
import type { PatchInstanceGeneralSettings, BackupRetentionDays } from "@paperclipai/shared";
|
||||||
import { LogOut, SlidersHorizontal } from "lucide-react";
|
import { BACKUP_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION_DAYS } from "@paperclipai/shared";
|
||||||
|
import { Database, LogOut, SlidersHorizontal } from "lucide-react";
|
||||||
import { authApi } from "@/api/auth";
|
import { authApi } from "@/api/auth";
|
||||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
|
|
@ -67,6 +68,7 @@ export function InstanceGeneralSettings() {
|
||||||
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
|
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
|
||||||
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
|
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
|
||||||
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
||||||
|
const backupRetentionDays: BackupRetentionDays = generalQuery.data?.backupRetentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl space-y-6">
|
<div className="max-w-4xl space-y-6">
|
||||||
|
|
@ -123,6 +125,49 @@ export function InstanceGeneralSettings() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-sm font-semibold">Backup retention</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
How long to keep automatic database backups before pruning. Backups are compressed
|
||||||
|
with gzip to minimize disk usage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BACKUP_RETENTION_PRESETS.map((days) => {
|
||||||
|
const active = backupRetentionDays === days;
|
||||||
|
const label =
|
||||||
|
days === 7 ? "7 days" : days === 14 ? "2 weeks" : "1 month";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={days}
|
||||||
|
type="button"
|
||||||
|
disabled={updateGeneralMutation.isPending}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||||
|
active
|
||||||
|
? "border-foreground bg-accent text-foreground"
|
||||||
|
: "border-border bg-background hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
updateGeneralMutation.mutate({ backupRetentionDays: days })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-sm font-medium">{label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Keep backups for {days} days
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-5">
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue