Files
AgentHub/client/src/components/settings/RetentionSettings.tsx
T
nntrivi2001 e07993c536 perf: hourly update check only with a browser open; opt-in retention, incremental auto-vacuum and Compact
- update-scheduler: default interval 1 h (DASHBOARD_UPDATE_CHECK_INTERVAL_MS and its 60 s floor kept);
  a scheduled tick with no /ws client is skipped, and the next browser to connect gets one catch-up
  check when the last one is stale (websocket.js onConnection). Manual check unchanged.
- db-retention.js: shared session purge (used by POST /api/settings/cleanup too), opt-in
  AGENTHUB_RETENTION_DAYS applied by the hourly maintenance in 25-session transactions with yields
  (max 5000/run), bounded incremental_vacuum when the freelist is large, and a manual Compact
  (VACUUM in a worker, converting to auto_vacuum=INCREMENTAL).
- db.js: new databases are INCREMENTAL; an existing one up to 32 MB is converted at startup.
- GET/PUT /api/settings/retention, POST /api/settings/compact (confirm: true); Settings → Data →
  Session cleanup gets the Automatic cleanup control and Compact now (en + vi).
2026-10-16 10:20:38 +07:00

181 lines
6.3 KiB
TypeScript

/**
* @file RetentionSettings.tsx
* @description Settings → Data → Session cleanup, under the manual purge: the
* OPT-IN automatic cleanup (`AGENTHUB_RETENTION_DAYS`, 0 = off, the default)
* that the hourly maintenance applies, the database's size and free space, and
* **Compact now** — a full VACUUM behind a confirm, polled through
* `GET /api/settings/retention` while it runs, then toasted. Backed by
* `server/lib/db-retention.js`; a value the environment set is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Minimize2, RefreshCw } from "lucide-react";
import { api } from "../../lib/api";
import type { RetentionState } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { formatBytes, formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
export function RetentionSettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<RetentionState | null>(null);
const [days, setDays] = useState("0");
const [saving, setSaving] = useState(false);
useEffect(() => {
api.settings.retention
.get()
.then((s) => {
setState(s);
setDays(String(s.days));
})
.catch(() => undefined);
}, []);
// A Compact takes a while on a large database: poll until it ends.
const compacting = !!state?.compacting;
useEffect(() => {
if (!compacting) return;
const id = window.setInterval(() => {
api.settings.retention
.get()
.then((s) => {
setState(s); // the draft keeps its value
if (s.compacting || !s.last_compact) return;
if (s.last_compact.ok) {
toast.success(
t("retention.compactDone", {
before: formatBytes(s.last_compact.size_before ?? 0),
after: formatBytes(s.last_compact.size_after ?? 0),
})
);
} else {
toast.error(t("retention.compactFailed", { error: s.last_compact.error }));
}
})
.catch(() => undefined);
}, 2000);
return () => window.clearInterval(id);
}, [compacting, t]);
const max = state?.max_days ?? 3650;
const n = Number(days);
const valid = days.trim() !== "" && Number.isInteger(n) && n >= 0 && n <= max;
const changed = state !== null && valid && n !== state.days;
const owned = !!state?.env_owned.days;
async function save() {
setSaving(true);
try {
const s = await api.settings.retention.save(n);
setState(s);
setDays(String(s.days));
toast.success(t("retention.saved"));
} catch (err) {
toast.error(err);
} finally {
setSaving(false);
}
}
async function compact() {
const ok = await confirmDialog({
title: t("retention.compactConfirmTitle"),
message: t("retention.compactConfirmBody"),
confirmLabel: t("retention.compact"),
});
if (!ok) return;
try {
setState(await api.settings.retention.compact());
} catch (err) {
toast.error(err);
}
}
const purged = state?.last_run?.retention?.purged_sessions;
return (
<div className="border-t border-border pt-4 space-y-3">
<div>
<h4 className="text-xs font-medium text-fg-secondary">{t("retention.title")}</h4>
<p className="text-xs text-fg-muted">{t("retention.description")}</p>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label htmlFor="retention-days" className="text-xs text-fg-secondary block mb-2">
{t("retention.days")}
</label>
<div className="flex items-center gap-2">
<input
id="retention-days"
type="number"
min="0"
max={max}
step="1"
inputMode="numeric"
value={days}
disabled={!state || owned}
onChange={(e) => setDays(e.target.value)}
aria-invalid={!valid || undefined}
aria-describedby={!valid ? "retention-days-error" : undefined}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:days")}</span>
</div>
{!valid && (
<p id="retention-days-error" className="mt-1 text-[11px] text-status-danger">
{t("retention.invalid", { max })}
</p>
)}
</div>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !changed || owned}
onClick={() => void save()}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
</div>
<p className="text-xs text-fg-secondary">
{state && state.days > 0 ? t("retention.on", { days: state.days }) : t("retention.off")}
{state?.last_run && purged !== undefined && (
<> {t("retention.lastRun", { time: formatDateTime(state.last_run.at), n: purged })}</>
)}
</p>
{owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => void compact()}
disabled={!state || compacting}
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
>
{compacting ? (
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
) : (
<Minimize2 className="w-3 h-3 inline mr-1" />
)}
{compacting ? t("retention.compacting") : t("retention.compact")}
</button>
{state?.space && (
<span className="text-xs text-fg-muted">
{t("retention.space", {
size: formatBytes(state.db_size),
free: formatBytes(state.space.free_bytes),
mode: state.space.auto_vacuum,
})}
</span>
)}
</div>
</div>
);
}