feat(db): rebuild Codex usage on upgrade and via maintenance action

Schema v16 wipes codex_session detail rows, _codex_session rollups and
Codex rollout cursors inside the migration savepoint (cursor deletion
uses pure shape matching so CODEX_HOME drift cannot orphan cursors);
the next session sync re-imports history from source JSONL under the
corrected importer. Fresh installs traverse the same branch as a no-op.

Add a manual "Rebuild Codex usage" maintenance action (single-flight,
hard-fail backup before reset, unconditional refresh notification even
when reimport is empty or fails after reset) with a destructive confirm
dialog, result toast and four-locale strings. Historical proxy-side
duplicate rows are intentionally left untouched; history whose source
JSONL was already deleted cannot be reconstructed.
This commit is contained in:
Jason
2026-07-20 12:18:47 +08:00
parent c9ac6efd69
commit eff1e0ccfc
11 changed files with 258 additions and 1 deletions
+84
View File
@@ -19,6 +19,8 @@ import {
RefreshCw,
Coins,
LayoutGrid,
DatabaseBackup,
Loader2,
} from "lucide-react";
import { ProviderIcon } from "@/components/ProviderIcon";
import {
@@ -43,6 +45,10 @@ import { getLocaleFromLanguage } from "./format";
import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange";
import { UsageDateRangePicker } from "./UsageDateRangePicker";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { usageApi } from "@/lib/api/usage";
import { toast } from "sonner";
const APP_FILTER_OPTIONS: AppTypeFilter[] = ["all", ...KNOWN_APP_TYPES];
@@ -94,6 +100,8 @@ export function UsageDashboard({
const [refreshIntervalMs, setRefreshIntervalMs] = useState(() =>
normalizeRefreshInterval(savedRefreshIntervalMs),
);
const [showRebuildConfirm, setShowRebuildConfirm] = useState(false);
const [rebuildingCodex, setRebuildingCodex] = useState(false);
useEffect(() => {
setRefreshIntervalMs(normalizeRefreshInterval(savedRefreshIntervalMs));
@@ -138,6 +146,34 @@ export function UsageDashboard({
}
};
const rebuildCodexUsage = async () => {
setShowRebuildConfirm(false);
setRebuildingCodex(true);
try {
const result = await usageApi.rebuildCodexUsage();
await queryClient.invalidateQueries({ queryKey: usageKeys.all });
const message = t("usage.rebuildCodex.completed", {
imported: result.imported,
errors: result.errors.length,
suspected: result.suspectedDuplicates,
deferred: result.deferredFiles,
});
if (result.errors.length > 0 || result.deferredFiles > 0) {
toast.warning(message);
} else {
toast.success(message);
}
} catch (error) {
toast.error(
t("usage.rebuildCodex.failed", {
error: String(error),
}),
);
} finally {
setRebuildingCodex(false);
}
};
const language = i18n.resolvedLanguage || i18n.language || "en";
const locale = getLocaleFromLanguage(language);
const resolvedRange = useMemo(() => resolveUsageRange(range), [range]);
@@ -429,7 +465,55 @@ export function UsageDashboard({
<PricingConfigPanel />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="maintenance"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<DatabaseBackup className="h-5 w-5 text-orange-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("usage.rebuildCodex.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("usage.rebuildCodex.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<div className="flex items-center justify-between gap-4 rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="text-sm text-muted-foreground">
{t("usage.rebuildCodex.warning")}
</p>
<Button
variant="destructive"
disabled={rebuildingCodex}
onClick={() => setShowRebuildConfirm(true)}
className="shrink-0"
>
{rebuildingCodex ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<DatabaseBackup className="mr-2 h-4 w-4" />
)}
{t("usage.rebuildCodex.action")}
</Button>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<ConfirmDialog
isOpen={showRebuildConfirm}
title={t("usage.rebuildCodex.confirmTitle")}
message={t("usage.rebuildCodex.confirmMessage")}
confirmText={t("usage.rebuildCodex.confirmAction")}
variant="destructive"
onConfirm={() => void rebuildCodexUsage()}
onCancel={() => setShowRebuildConfirm(false)}
/>
</motion.div>
);
}