Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -912,8 +912,8 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `TAILSCALE_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscale` binary. |
| `TAILSCALED_BIN` | _(auto-detect)_ | `src/lib/tailscaleTunnel.ts` | Explicit path to the `tailscaled` daemon binary. |
| `NGROK_AUTHTOKEN` | _(unset)_ | `src/lib/ngrokTunnel.ts` | Authenticates outbound ngrok tunnels. |
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. |
| `DB_BACKUP_MAX_FILES` | `20` | `src/lib/db/backup.ts` | Maximum SQLite backup files retained on disk. Overrides the value saved from Settings → Database backup retention. |
| `DB_BACKUP_RETENTION_DAYS` | `0` | `src/lib/db/backup.ts` | Maximum age (days) of retained backups. `0` disables age-based pruning. Overrides the value saved from Settings → Database backup retention. |
| `OMNIROUTE_TLS_PROXY_URL` | _(unset)_ | `open-sse/services/chatgptTlsClient.ts` | Override the TLS sidecar URL for tests. Production should leave unset. |
| `CONTAINER_HOST` | `docker` | `scripts/check-permissions.sh` | Container runtime hint for the entrypoint permission check. Set to `podman` under rootless Podman so the fix instructions use `podman unshare chown` instead of `sudo chown`. |
| `QUOTA_STORE_DRIVER` | `sqlite` | `src/lib/quota/storeFactory.ts` | Quota-share consumption store backend: `sqlite` (default) or `redis`. |
Expand Down
21 changes: 21 additions & 0 deletions docs/reference/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3496,6 +3496,27 @@ paths:
responses:
"200":
description: Backup created
patch:
tags: [System]
summary: Save database backup retention settings
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
keepLatest:
type: integer
minimum: 1
maximum: 200
retentionDays:
type: integer
minimum: 0
maximum: 3650
responses:
"200":
description: Backup retention settings saved

/api/storage/health:
get:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"use client";

import type { Dispatch, SetStateAction } from "react";
import { Badge, Button } from "@/shared/components";

export type BackupCleanupOptions = {
keepLatest: number;
retentionDays: number;
};

export type BackupRetentionStatus = {
type: string;
message: string;
};

export type StorageBackupHealth = {
backupCount?: number;
backupRetention: {
maxFiles: number;
days: number;
};
};

type DatabaseBackupRetentionCardProps = {
title: string;
storageHealth: StorageBackupHealth;
backupCleanupOptions: BackupCleanupOptions;
setBackupCleanupOptions: Dispatch<SetStateAction<BackupCleanupOptions>>;
saveBackupRetentionLoading: boolean;
backupRetentionStatus: BackupRetentionStatus;
setBackupRetentionStatus: Dispatch<SetStateAction<BackupRetentionStatus>>;
cleanupBackupsLoading: boolean;
cleanupBackupsStatus: BackupRetentionStatus;
onSaveRetention: () => void;
onCleanupBackups: () => void;
};

function StatusAlert({ status }: { status: BackupRetentionStatus }) {
if (!status.message) return null;

const isSuccess = status.type === "success";
return (
<div
className={`mt-3 p-3 rounded-lg text-sm ${
isSuccess
? "bg-green-500/10 text-green-500 border border-green-500/20"
: "bg-red-500/10 text-red-500 border border-red-500/20"
}`}
role="alert"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{isSuccess ? "check_circle" : "error"}
</span>
{status.message}
</div>
</div>
);
}

export default function DatabaseBackupRetentionCard({
title,
storageHealth,
backupCleanupOptions,
setBackupCleanupOptions,
saveBackupRetentionLoading,
backupRetentionStatus,
setBackupRetentionStatus,
cleanupBackupsLoading,
cleanupBackupsStatus,
onSaveRetention,
onCleanupBackups,
}: DatabaseBackupRetentionCardProps) {
return (
<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap mb-3">
<div>
<p className="text-sm font-medium text-text-main">{title}</p>
<p className="text-xs text-text-muted">
Automatic SQLite backups are stored in <code>db_backups</code>. Configure how many
snapshots to keep and optionally delete backups older than N days.
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="default" size="sm">
{storageHealth.backupCount || 0} backups
</Badge>
<Badge variant="default" size="sm">
Max {storageHealth.backupRetention.maxFiles}
</Badge>
<Badge variant="default" size="sm">
{storageHealth.backupRetention.days > 0
? `${storageHealth.backupRetention.days}d retention`
: "Age retention off"}
</Badge>
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-xs text-text-muted">
Keep latest backups
<input
type="number"
min={1}
max={200}
value={backupCleanupOptions.keepLatest}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value || "1", 10);
setBackupRetentionStatus({ type: "", message: "" });
setBackupCleanupOptions((prev) => ({
...prev,
keepLatest: Number.isFinite(parsed) ? Math.max(1, parsed) : 1,
}));
}}
className="h-9 w-32 rounded-lg border border-border bg-background px-3 text-sm text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-text-muted">
Delete older than days
<input
type="number"
min={0}
max={3650}
value={backupCleanupOptions.retentionDays}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value || "0", 10);
setBackupRetentionStatus({ type: "", message: "" });
setBackupCleanupOptions((prev) => ({
...prev,
retentionDays: Number.isFinite(parsed) ? Math.max(0, parsed) : 0,
}));
}}
className="h-9 w-32 rounded-lg border border-border bg-background px-3 text-sm text-text-main"
/>
</label>
<Button
variant="outline"
size="sm"
onClick={onSaveRetention}
loading={saveBackupRetentionLoading}
>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
save
</span>
Save retention
</Button>
<Button
variant="outline"
size="sm"
onClick={onCleanupBackups}
loading={cleanupBackupsLoading}
>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
auto_delete
</span>
Clean old backups
</Button>
</div>
<StatusAlert status={backupRetentionStatus} />
<StatusAlert status={cleanupBackupsStatus} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, Badge, Toggle } from "@/shared/components";
import { useLocale, useTranslations } from "next-intl";
import DatabaseBackupRetentionCard from "./DatabaseBackupRetentionCard";

const rowCountFormatter = new Intl.NumberFormat("en-US");

Expand Down Expand Up @@ -30,6 +31,8 @@ export default function SystemStorageTab() {
const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" });
const [cleanupBackupsLoading, setCleanupBackupsLoading] = useState(false);
const [cleanupBackupsStatus, setCleanupBackupsStatus] = useState({ type: "", message: "" });
const [saveBackupRetentionLoading, setSaveBackupRetentionLoading] = useState(false);
const [backupRetentionStatus, setBackupRetentionStatus] = useState({ type: "", message: "" });
const [purgeQuotaSnapshotsLoading, setPurgeQuotaSnapshotsLoading] = useState(false);
const [purgeQuotaSnapshotsStatus, setPurgeQuotaSnapshotsStatus] = useState({
type: "",
Expand Down Expand Up @@ -154,6 +157,35 @@ export default function SystemStorageTab() {
}
};

const handleSaveBackupRetention = async () => {
setSaveBackupRetentionLoading(true);
setBackupRetentionStatus({ type: "", message: "" });
try {
const res = await fetch("/api/db-backups", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(backupCleanupOptions),
});
const data = await res.json();
if (res.ok) {
setBackupRetentionStatus({
type: "success",
message: "Backup retention saved.",
});
await loadStorageHealth();
} else {
setBackupRetentionStatus({
type: "error",
message: data.error || "Failed to save backup retention",
});
}
} catch {
setBackupRetentionStatus({ type: "error", message: t("errorOccurred") });
} finally {
setSaveBackupRetentionLoading(false);
}
};

const handleCleanupBackups = async () => {
setCleanupBackupsLoading(true);
setCleanupBackupsStatus({ type: "", message: "" });
Expand Down Expand Up @@ -663,96 +695,19 @@ export default function SystemStorageTab() {
</div>
</div>

<div className="p-3 rounded-lg bg-bg border border-border mb-4">
<div className="flex items-start justify-between gap-3 flex-wrap mb-3">
<div>
<p className="text-sm font-medium text-text-main">
{t("storageDatabaseBackupRetention")}
</p>
<p className="text-xs text-text-muted">
Automatic SQLite backups are stored in <code>db_backups</code>. Configure how many
snapshots to keep and optionally delete backups older than N days.
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="default" size="sm">
{storageHealth.backupCount || 0} backups
</Badge>
<Badge variant="default" size="sm">
Max {storageHealth.backupRetention.maxFiles}
</Badge>
<Badge variant="default" size="sm">
{storageHealth.backupRetention.days > 0
? `${storageHealth.backupRetention.days}d retention`
: "Age retention off"}
</Badge>
</div>
</div>
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1 text-xs text-text-muted">
Keep latest backups
<input
type="number"
min={1}
max={200}
value={backupCleanupOptions.keepLatest}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value || "1", 10);
setBackupCleanupOptions((prev) => ({
...prev,
keepLatest: Number.isFinite(parsed) ? Math.max(1, parsed) : 1,
}));
}}
className="h-9 w-32 rounded-lg border border-border bg-background px-3 text-sm text-text-main"
/>
</label>
<label className="flex flex-col gap-1 text-xs text-text-muted">
Delete older than days
<input
type="number"
min={0}
max={3650}
value={backupCleanupOptions.retentionDays}
onChange={(e) => {
const parsed = Number.parseInt(e.target.value || "0", 10);
setBackupCleanupOptions((prev) => ({
...prev,
retentionDays: Number.isFinite(parsed) ? Math.max(0, parsed) : 0,
}));
}}
className="h-9 w-32 rounded-lg border border-border bg-background px-3 text-sm text-text-main"
/>
</label>
<Button
variant="outline"
size="sm"
onClick={handleCleanupBackups}
loading={cleanupBackupsLoading}
>
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
auto_delete
</span>
Clean old backups
</Button>
</div>
{cleanupBackupsStatus.message && (
<div
className={`mt-3 p-3 rounded-lg text-sm ${
cleanupBackupsStatus.type === "success"
? "bg-green-500/10 text-green-500 border border-green-500/20"
: "bg-red-500/10 text-red-500 border border-red-500/20"
}`}
role="alert"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{cleanupBackupsStatus.type === "success" ? "check_circle" : "error"}
</span>
{cleanupBackupsStatus.message}
</div>
</div>
)}
</div>
<DatabaseBackupRetentionCard
title={t("storageDatabaseBackupRetention")}
storageHealth={storageHealth}
backupCleanupOptions={backupCleanupOptions}
setBackupCleanupOptions={setBackupCleanupOptions}
saveBackupRetentionLoading={saveBackupRetentionLoading}
backupRetentionStatus={backupRetentionStatus}
setBackupRetentionStatus={setBackupRetentionStatus}
cleanupBackupsLoading={cleanupBackupsLoading}
cleanupBackupsStatus={cleanupBackupsStatus}
onSaveRetention={handleSaveBackupRetention}
onCleanupBackups={handleCleanupBackups}
/>

{/* Export / Import */}
<div className="flex flex-wrap items-center gap-2 mb-4">
Expand Down
Loading