mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
fix: move storage analysis to account menu
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
Store,
|
||||
Github,
|
||||
Zap,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -55,6 +56,7 @@ import AccountSettingsDialog from '@/app/home/components/account-settings-dialog
|
||||
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
|
||||
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { toast } from 'sonner';
|
||||
@@ -1185,6 +1187,9 @@ export default function HomeSidebar({
|
||||
if (searchParams.get('action') === 'showApiIntegrationSettings') {
|
||||
setApiKeyDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showStorageAnalysis') {
|
||||
setStorageAnalysisOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
|
||||
@@ -1200,6 +1205,7 @@ export default function HomeSidebar({
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -1239,6 +1245,24 @@ export default function HomeSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
function handleStorageAnalysisChange(open: boolean) {
|
||||
setStorageAnalysisOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showStorageAnalysis');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initSelect();
|
||||
if (!localStorage.getItem('token')) {
|
||||
@@ -1545,6 +1569,15 @@ export default function HomeSidebar({
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
handleStorageAnalysisChange(true);
|
||||
}}
|
||||
>
|
||||
<HardDrive />
|
||||
{t('storageAnalysis.title')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
@@ -1645,6 +1678,10 @@ export default function HomeSidebar({
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
<StorageAnalysisDialog
|
||||
open={storageAnalysisOpen}
|
||||
onOpenChange={handleStorageAnalysisChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||
import i18n from '@/i18n';
|
||||
import { HardDrive } from 'lucide-react';
|
||||
|
||||
const t = (key: string) => {
|
||||
return i18n.t(key);
|
||||
@@ -52,18 +51,6 @@ export const sidebarConfigList = [
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'storage-analysis',
|
||||
name: t('storageAnalysis.title'),
|
||||
icon: <HardDrive className="text-blue-500" />,
|
||||
route: '/home/storage-analysis',
|
||||
description: t('storageAnalysis.description'),
|
||||
helpLink: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'bots',
|
||||
name: t('bots.title'),
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Clock,
|
||||
Database,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
modified_at?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface StorageAnalysisDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: StorageAnalysisDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnalysis();
|
||||
}
|
||||
}, [loadAnalysis, open]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[86vh] max-w-5xl flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="size-5 text-blue-500" />
|
||||
{t('storageAnalysis.dialogTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('storageAnalysis.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-b px-6 pb-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
meta={`${analysis.database.binary_storage.count}`}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.log_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border px-3 py-3">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
{t('storageAnalysis.cleanupPolicy')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.uploadRetention')}
|
||||
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.logRetention')}
|
||||
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.databaseType')}
|
||||
value={analysis.database.type}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={section.exists ? 'secondary' : 'outline'}
|
||||
className="self-center"
|
||||
>
|
||||
{section.exists
|
||||
? t('storageAnalysis.exists')
|
||||
: t('storageAnalysis.missing')}
|
||||
</Badge>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.monitoringTables')}
|
||||
values={analysis.database.monitoring_counts}
|
||||
/>
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.runtimeTasks')}
|
||||
values={analysis.tasks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredUploads')}
|
||||
emptyText={t('storageAnalysis.noExpiredUploads')}
|
||||
candidates={analysis.cleanup_candidates.uploaded_files}
|
||||
/>
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredLogs')}
|
||||
emptyText={t('storageAnalysis.noExpiredLogs')}
|
||||
candidates={analysis.cleanup_candidates.log_files}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
meta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value}</span>
|
||||
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPanel({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">{title}</h2>
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatePanel({
|
||||
title,
|
||||
emptyText,
|
||||
candidates,
|
||||
}: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
candidates: CleanupCandidate[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
candidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.key ?? candidate.name}-${index}`}
|
||||
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{candidate.key ?? candidate.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{candidate.modified_at ?? candidate.date ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(candidate.size_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, HardDrive, Database, FileWarning } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: {
|
||||
total?: number;
|
||||
running?: number;
|
||||
completed?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisPage() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalysis();
|
||||
}, [loadAnalysis]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<div className="h-full px-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{t('storageAnalysis.title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t('storageAnalysis.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="outline">
|
||||
<HardDrive className="mr-2 size-4" />
|
||||
{t('storageAnalysis.openDialog')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[82vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="size-5 text-blue-500" />
|
||||
{t('storageAnalysis.dialogTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.monitoringTables')}
|
||||
</h2>
|
||||
<KeyValueList values={analysis.database.monitoring_counts} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.runtimeTasks')}
|
||||
</h2>
|
||||
<KeyValueList values={analysis.tasks} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueList({
|
||||
values,
|
||||
}: {
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1240,6 +1240,17 @@ const enUS = {
|
||||
sections: 'Storage sections',
|
||||
monitoringTables: 'Monitoring tables',
|
||||
runtimeTasks: 'Runtime tasks',
|
||||
cleanupPolicy: 'Cleanup policy',
|
||||
uploadRetention: 'Upload retention',
|
||||
logRetention: 'Log retention',
|
||||
databaseType: 'Database type',
|
||||
days: 'days',
|
||||
exists: 'Exists',
|
||||
missing: 'Missing',
|
||||
expiredUploads: 'Expired uploads',
|
||||
expiredLogs: 'Expired logs',
|
||||
noExpiredUploads: 'No expired uploaded files',
|
||||
noExpiredLogs: 'No expired log files',
|
||||
sectionNames: {
|
||||
database: 'Database',
|
||||
logs: 'Logs',
|
||||
|
||||
@@ -1275,6 +1275,17 @@ const esES = {
|
||||
sections: 'Secciones de almacenamiento',
|
||||
monitoringTables: 'Tablas de monitoreo',
|
||||
runtimeTasks: 'Tareas en ejecución',
|
||||
cleanupPolicy: 'Política de limpieza',
|
||||
uploadRetention: 'Retención de subidas',
|
||||
logRetention: 'Retención de registros',
|
||||
databaseType: 'Tipo de base de datos',
|
||||
days: 'días',
|
||||
exists: 'Existe',
|
||||
missing: 'Falta',
|
||||
expiredUploads: 'Subidas caducadas',
|
||||
expiredLogs: 'Registros caducados',
|
||||
noExpiredUploads: 'No hay archivos subidos caducados',
|
||||
noExpiredLogs: 'No hay registros caducados',
|
||||
sectionNames: {
|
||||
database: 'Base de datos',
|
||||
logs: 'Registros',
|
||||
|
||||
@@ -1245,6 +1245,17 @@
|
||||
sections: 'ストレージセクション',
|
||||
monitoringTables: '監視テーブル',
|
||||
runtimeTasks: '実行タスク',
|
||||
cleanupPolicy: 'クリーンアップポリシー',
|
||||
uploadRetention: 'アップロード保持期間',
|
||||
logRetention: 'ログ保持期間',
|
||||
databaseType: 'データベース種別',
|
||||
days: '日',
|
||||
exists: '存在',
|
||||
missing: 'なし',
|
||||
expiredUploads: '期限切れアップロード',
|
||||
expiredLogs: '期限切れログ',
|
||||
noExpiredUploads: '期限切れのアップロードファイルはありません',
|
||||
noExpiredLogs: '期限切れのログファイルはありません',
|
||||
sectionNames: {
|
||||
database: 'データベース',
|
||||
logs: 'ログ',
|
||||
|
||||
@@ -1249,6 +1249,17 @@ const ruRU = {
|
||||
sections: 'Разделы хранилища',
|
||||
monitoringTables: 'Таблицы мониторинга',
|
||||
runtimeTasks: 'Задачи runtime',
|
||||
cleanupPolicy: 'Политика очистки',
|
||||
uploadRetention: 'Хранение загрузок',
|
||||
logRetention: 'Хранение журналов',
|
||||
databaseType: 'Тип базы данных',
|
||||
days: 'дн.',
|
||||
exists: 'Есть',
|
||||
missing: 'Нет',
|
||||
expiredUploads: 'Просроченные загрузки',
|
||||
expiredLogs: 'Просроченные журналы',
|
||||
noExpiredUploads: 'Нет просроченных загруженных файлов',
|
||||
noExpiredLogs: 'Нет просроченных журналов',
|
||||
sectionNames: {
|
||||
database: 'База данных',
|
||||
logs: 'Журналы',
|
||||
|
||||
@@ -1220,6 +1220,17 @@ const thTH = {
|
||||
sections: 'ส่วนพื้นที่จัดเก็บ',
|
||||
monitoringTables: 'ตารางการตรวจสอบ',
|
||||
runtimeTasks: 'งาน runtime',
|
||||
cleanupPolicy: 'นโยบายการล้างข้อมูล',
|
||||
uploadRetention: 'ระยะเวลาเก็บไฟล์อัปโหลด',
|
||||
logRetention: 'ระยะเวลาเก็บบันทึก',
|
||||
databaseType: 'ชนิดฐานข้อมูล',
|
||||
days: 'วัน',
|
||||
exists: 'มีอยู่',
|
||||
missing: 'ไม่มี',
|
||||
expiredUploads: 'ไฟล์อัปโหลดที่หมดอายุ',
|
||||
expiredLogs: 'บันทึกที่หมดอายุ',
|
||||
noExpiredUploads: 'ไม่มีไฟล์อัปโหลดที่หมดอายุ',
|
||||
noExpiredLogs: 'ไม่มีบันทึกที่หมดอายุ',
|
||||
sectionNames: {
|
||||
database: 'ฐานข้อมูล',
|
||||
logs: 'บันทึก',
|
||||
|
||||
@@ -1242,6 +1242,17 @@ const viVN = {
|
||||
sections: 'Khu vực lưu trữ',
|
||||
monitoringTables: 'Bảng giám sát',
|
||||
runtimeTasks: 'Tác vụ runtime',
|
||||
cleanupPolicy: 'Chính sách dọn dẹp',
|
||||
uploadRetention: 'Thời gian giữ tệp tải lên',
|
||||
logRetention: 'Thời gian giữ nhật ký',
|
||||
databaseType: 'Loại cơ sở dữ liệu',
|
||||
days: 'ngày',
|
||||
exists: 'Tồn tại',
|
||||
missing: 'Thiếu',
|
||||
expiredUploads: 'Tệp tải lên hết hạn',
|
||||
expiredLogs: 'Nhật ký hết hạn',
|
||||
noExpiredUploads: 'Không có tệp tải lên hết hạn',
|
||||
noExpiredLogs: 'Không có nhật ký hết hạn',
|
||||
sectionNames: {
|
||||
database: 'Cơ sở dữ liệu',
|
||||
logs: 'Nhật ký',
|
||||
|
||||
@@ -1186,6 +1186,17 @@ const zhHans = {
|
||||
sections: '存储分区',
|
||||
monitoringTables: '监控表',
|
||||
runtimeTasks: '运行任务',
|
||||
cleanupPolicy: '清理策略',
|
||||
uploadRetention: '上传文件保留',
|
||||
logRetention: '日志保留',
|
||||
databaseType: '数据库类型',
|
||||
days: '天',
|
||||
exists: '存在',
|
||||
missing: '不存在',
|
||||
expiredUploads: '过期上传文件',
|
||||
expiredLogs: '过期日志',
|
||||
noExpiredUploads: '暂无过期上传文件',
|
||||
noExpiredLogs: '暂无过期日志',
|
||||
sectionNames: {
|
||||
database: '数据库',
|
||||
logs: '日志',
|
||||
|
||||
@@ -1186,6 +1186,17 @@ const zhHant = {
|
||||
sections: '儲存分區',
|
||||
monitoringTables: '監控表',
|
||||
runtimeTasks: '執行任務',
|
||||
cleanupPolicy: '清理策略',
|
||||
uploadRetention: '上傳檔案保留',
|
||||
logRetention: '日誌保留',
|
||||
databaseType: '資料庫類型',
|
||||
days: '天',
|
||||
exists: '存在',
|
||||
missing: '不存在',
|
||||
expiredUploads: '過期上傳檔案',
|
||||
expiredLogs: '過期日誌',
|
||||
noExpiredUploads: '暫無過期上傳檔案',
|
||||
noExpiredLogs: '暫無過期日誌',
|
||||
sectionNames: {
|
||||
database: '資料庫',
|
||||
logs: '日誌',
|
||||
|
||||
Reference in New Issue
Block a user