e07993c536
- 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).
181 lines
6.3 KiB
TypeScript
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>
|
|
);
|
|
}
|