feat(settings): add local storage tab to display IndexedDB usage and site quota
This commit is contained in:
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
|
||||
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
|
||||
import { ConfigLocalStorage } from "@/components/layout/config-local-storage";
|
||||
import type { AppLocale } from "@/i18n";
|
||||
import { exportAppConfig, importAppConfig } from "@/services/config-file";
|
||||
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
||||
@@ -311,6 +312,11 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "local-storage",
|
||||
label: t("config.tabs.localStorage"),
|
||||
children: <ConfigLocalStorage active={activeTab === "local-storage"} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{showDoneButton ? (
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Alert, Button, Progress, Spin } from "antd";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Database, HardDrive, Layers3, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { readLocalStorageUsage, type LocalStorageUsage } from "@/services/local-storage-usage";
|
||||
|
||||
const storeLabelKeys: Record<string, string> = {
|
||||
app_state: "appState",
|
||||
image_files: "images",
|
||||
media_files: "media",
|
||||
image_generation_logs: "imageLogs",
|
||||
video_generation_logs: "videoLogs",
|
||||
agent_chat_messages: "agentMessages",
|
||||
prompt_cache: "promptCache",
|
||||
};
|
||||
|
||||
export function ConfigLocalStorage({ active }: { active: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [usage, setUsage] = useState<LocalStorageUsage | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
setUsage(await readLocalStorageUsage());
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : t("config.localStorage.readFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active && !usage) void refresh();
|
||||
}, [active, refresh, usage]);
|
||||
|
||||
const indexedDbBytes = usage?.contentBytes ?? 0;
|
||||
const percent = usage ? Math.min(100, (usage.usage / usage.quota) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<section className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Database className="size-4" />
|
||||
{t("config.localStorage.title")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-stone-500">{t("config.localStorage.description")}</div>
|
||||
</div>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void refresh()}>
|
||||
{t("config.localStorage.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <Alert className="mt-4" type="error" showIcon message={t("config.localStorage.readFailed")} description={error} /> : null}
|
||||
{!usage && loading ? (
|
||||
<div className="flex min-h-48 items-center justify-center"><Spin /></div>
|
||||
) : usage ? (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<StorageMetric icon={<Database className="size-4" />} label={t("config.localStorage.indexedDbUsage")} value={formatStorageBytes(indexedDbBytes)} hint={t("config.localStorage.contentEstimate")} />
|
||||
<StorageMetric icon={<HardDrive className="size-4" />} label={t("config.localStorage.siteUsage")} value={formatStorageBytes(usage.usage)} hint={t("config.localStorage.siteUsageHint")} />
|
||||
<StorageMetric icon={<Layers3 className="size-4" />} label={t("config.localStorage.quota")} value={formatStorageBytes(usage.quota)} hint={t("config.localStorage.quotaHint")} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 flex justify-between text-xs text-stone-500">
|
||||
<span>{t("config.localStorage.quotaProgress")}</span>
|
||||
<span className="tabular-nums">{percent.toFixed(2)}%</span>
|
||||
</div>
|
||||
<Progress percent={percent} showInfo={false} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
{usage?.databases.map((database) => (
|
||||
<section key={database.name} className="overflow-hidden rounded-lg border border-stone-200 dark:border-stone-800">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-stone-200 px-4 py-3 dark:border-stone-800">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{t("config.localStorage.mainDatabase")}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-stone-500">{database.name} · v{database.version}</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-sm font-medium tabular-nums">{formatStorageBytes(database.bytes)}</div>
|
||||
</div>
|
||||
<div className="divide-y divide-stone-200 dark:divide-stone-800">
|
||||
{database.stores.map((store) => (
|
||||
<div key={store.name} className="grid grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-4 px-4 py-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{storeLabel(store.name, t)}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[11px] text-stone-500">{store.name}</div>
|
||||
</div>
|
||||
<div className="text-right text-xs text-stone-500 tabular-nums">{t("config.localStorage.records", { count: store.records })}</div>
|
||||
<div className="w-20 text-right font-medium tabular-nums">{formatStorageBytes(store.bytes)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageMetric({ icon, label, value, hint }: { icon: ReactNode; label: string; value: string; hint: string }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-stone-100/70 p-3 dark:bg-stone-900/70">
|
||||
<div className="flex items-center gap-2 text-xs text-stone-500">{icon}{label}</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">{value}</div>
|
||||
<div className="mt-1 text-[11px] text-stone-500">{hint}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function storeLabel(name: string, t: TFunction) {
|
||||
const key = storeLabelKeys[name];
|
||||
return key ? t(`config.localStorage.stores.${key}`) : name;
|
||||
}
|
||||
|
||||
function formatStorageBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
Reference in New Issue
Block a user