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>
);
}
+11
View File
@@ -1519,6 +1519,17 @@
"upToDate": "Session logs are up to date",
"failed": "Session sync failed"
},
"rebuildCodex": {
"title": "Codex Usage Maintenance",
"description": "Rebuild Codex session usage from local rollout logs",
"warning": "The database is backed up first. History whose source JSONL has been deleted cannot be re-imported.",
"action": "Rebuild Codex Usage",
"confirmTitle": "Rebuild Codex usage?",
"confirmMessage": "This clears existing Codex session details and rollups, then imports them again from local Codex logs.\n\nHistory with deleted logs will be lost. Forks whose parent rollout is missing will be deferred.",
"confirmAction": "Back Up and Rebuild",
"completed": "Rebuild complete: {{imported}} imported, {{errors}} errors, {{suspected}} suspected duplicates, {{deferred}} deferred files",
"failed": "Codex usage rebuild failed: {{error}}"
},
"totalRecords": "{{total}} records total",
"goToPage": "Go",
"pageInputPlaceholder": "Page",
+11
View File
@@ -1519,6 +1519,17 @@
"upToDate": "セッションログは最新です",
"failed": "セッション同期に失敗しました"
},
"rebuildCodex": {
"title": "Codex 使用量メンテナンス",
"description": "ローカルの rollout ログから Codex セッション使用量を再構築します",
"warning": "最初にデータベースを自動バックアップします。元の JSONL が削除済みの履歴は再インポートできません。",
"action": "Codex 使用量を再構築",
"confirmTitle": "Codex 使用量を再構築しますか?",
"confirmMessage": "既存の Codex セッション明細と集計を削除し、ローカルの Codex ログから再インポートします。\n\n削除済みログの履歴は失われます。親 rollout がない fork は保留されます。",
"confirmAction": "バックアップして再構築",
"completed": "再構築完了:{{imported}} 件インポート、{{errors}} 件エラー、重複候補 {{suspected}} 件、保留ファイル {{deferred}} 件",
"failed": "Codex 使用量の再構築に失敗しました:{{error}}"
},
"totalRecords": "全 {{total}} 件",
"goToPage": "移動",
"pageInputPlaceholder": "ページ",
+11
View File
@@ -1491,6 +1491,17 @@
"upToDate": "工作階段日誌已是最新",
"failed": "工作階段同步失敗"
},
"rebuildCodex": {
"title": "Codex 用量維護",
"description": "從本機 rollout 日誌重新建構 Codex 工作階段用量",
"warning": "重建前會自動備份資料庫。若來源 JSONL 已刪除,對應歷史將無法重新匯入。",
"action": "重建 Codex 用量",
"confirmTitle": "確認重建 Codex 用量",
"confirmMessage": "此操作會清除現有 Codex 工作階段明細與彙總,再從本機 Codex 日誌重新匯入。\n\n已刪除日誌對應的歷史將遺失;缺少父 rollout 的 fork 會暫緩匯入。",
"confirmAction": "備份並重建",
"completed": "重建完成:匯入 {{imported}} 筆,錯誤 {{errors}},疑似重複 {{suspected}},暫緩檔案 {{deferred}}",
"failed": "Codex 用量重建失敗:{{error}}"
},
"totalRecords": "共 {{total}} 筆紀錄",
"goToPage": "跳轉",
"pageInputPlaceholder": "頁碼",
+11
View File
@@ -1519,6 +1519,17 @@
"upToDate": "会话日志已是最新",
"failed": "会话同步失败"
},
"rebuildCodex": {
"title": "Codex 用量维护",
"description": "从本地 rollout 日志重新构建 Codex 会话用量",
"warning": "重建前会自动备份数据库。若源 JSONL 已删除,对应历史将无法重新导入。",
"action": "重建 Codex 用量",
"confirmTitle": "确认重建 Codex 用量",
"confirmMessage": "此操作会清除现有 Codex 会话明细与汇总,再从本地 Codex 日志重新导入。\n\n已删除日志对应的历史将丢失;缺少父 rollout 的 fork 会暂缓导入。",
"confirmAction": "备份并重建",
"completed": "重建完成:导入 {{imported}} 条,错误 {{errors}},疑似重复 {{suspected}},暂缓文件 {{deferred}}",
"failed": "Codex 用量重建失败:{{error}}"
},
"totalRecords": "共 {{total}} 条记录",
"goToPage": "跳转",
"pageInputPlaceholder": "页码",
+4
View File
@@ -180,6 +180,10 @@ export const usageApi = {
return invoke("sync_session_usage");
},
rebuildCodexUsage: async (): Promise<SessionSyncResult> => {
return invoke("rebuild_codex_usage");
},
getDataSourceBreakdown: async (): Promise<DataSourceSummary[]> => {
return invoke("get_usage_data_sources");
},