diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 01753556..4054bbcd 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -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(); @@ -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(''); const [starCount, setStarCount] = useState(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({ {t('account.settings')} + { + setUserMenuOpen(false); + handleStorageAnalysisChange(true); + }} + > + + {t('storageAnalysis.title')} + { setUserMenuOpen(false); @@ -1645,6 +1678,10 @@ export default function HomeSidebar({ open={modelsDialogOpen} onOpenChange={handleModelsDialogChange} /> + ); } diff --git a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx index 71b5cece..12e28121 100644 --- a/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx +++ b/web/src/app/home/components/home-sidebar/sidbarConfigList.tsx @@ -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: , - route: '/home/storage-analysis', - description: t('storageAnalysis.description'), - helpLink: { - en_US: '', - zh_Hans: '', - }, - section: 'home', - }), new SidebarChildVO({ id: 'bots', name: t('bots.title'), diff --git a/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisDialog.tsx b/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisDialog.tsx new file mode 100644 index 00000000..5b558d3d --- /dev/null +++ b/web/src/app/home/components/storage-analysis-dialog/StorageAnalysisDialog.tsx @@ -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; + binary_storage: { + count: number; + size_bytes: number | null; + }; + }; + cleanup_candidates: { + uploaded_files: CleanupCandidate[]; + log_files: CleanupCandidate[]; + }; + tasks: Record; +} + +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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadAnalysis = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await backendClient.get( + '/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 ( + + + + + + {t('storageAnalysis.dialogTitle')} + + + {t('storageAnalysis.description')} + + + +
+
+ {analysis + ? t('storageAnalysis.generatedAt', { + time: new Date(analysis.generated_at).toLocaleString(), + }) + : t('storageAnalysis.loading')} +
+ +
+ + +
+ {error && ( +
+ + {error} +
+ )} + + {analysis && ( + <> +
+ } + /> + } + /> + } + /> + } + /> +
+ +
+

+ + {t('storageAnalysis.cleanupPolicy')} +

+
+ + + +
+
+ +
+

+ {t('storageAnalysis.sections')} +

+
+ {analysis.sections.map((section) => ( +
+
+
+ {t(`storageAnalysis.sectionNames.${section.key}`)} +
+
+ {section.path || '-'} +
+
+ + {section.exists + ? t('storageAnalysis.exists') + : t('storageAnalysis.missing')} + +
+ {formatBytes(section.size_bytes)} +
+
+ {section.file_count} +
+
+ ))} +
+
+ +
+ + +
+ +
+ + +
+ + )} +
+
+
+
+ ); +} + +function SummaryItem({ + label, + value, + icon, + meta, +}: { + label: string; + value: string; + icon: ReactNode; + meta?: string; +}) { + return ( +
+
+ {icon} + {label} +
+
+ {value} + {meta && {meta}} +
+
+ ); +} + +function PolicyItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function MetricPanel({ + title, + values, +}: { + title: string; + values: Record; +}) { + return ( +
+

{title}

+
+ {Object.entries(values).map(([key, value]) => ( +
+ {key} + {value ?? '-'} +
+ ))} +
+
+ ); +} + +function CandidatePanel({ + title, + emptyText, + candidates, +}: { + title: string; + emptyText: string; + candidates: CleanupCandidate[]; +}) { + return ( +
+

+ + {title} +

+
+ {candidates.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + candidates.slice(0, 8).map((candidate, index) => ( +
+
+
+ {candidate.key ?? candidate.name} +
+
+ {candidate.modified_at ?? candidate.date ?? '-'} +
+
+
+ {formatBytes(candidate.size_bytes)} +
+
+ )) + )} +
+
+ ); +} diff --git a/web/src/app/home/storage-analysis/page.tsx b/web/src/app/home/storage-analysis/page.tsx deleted file mode 100644 index 270caff1..00000000 --- a/web/src/app/home/storage-analysis/page.tsx +++ /dev/null @@ -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; - 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(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const loadAnalysis = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await backendClient.get( - '/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 ( -
-
-
-

- {t('storageAnalysis.title')} -

-

- {t('storageAnalysis.description')} -

-
- -
- - - - - - - {t('storageAnalysis.dialogTitle')} - - - -
-
- {analysis - ? t('storageAnalysis.generatedAt', { - time: new Date(analysis.generated_at).toLocaleString(), - }) - : t('storageAnalysis.loading')} -
- -
- - {error && ( -
- {error} -
- )} - - {analysis && ( -
-
- } - /> - } - /> - } - /> - } - /> -
- -
-

- {t('storageAnalysis.sections')} -

-
- {analysis.sections.map((section) => ( -
-
-
- {t(`storageAnalysis.sectionNames.${section.key}`)} -
-
- {section.path} -
-
-
- {formatBytes(section.size_bytes)} -
-
- {section.file_count} -
-
- ))} -
-
- -
-
-

- {t('storageAnalysis.monitoringTables')} -

- -
-
-

- {t('storageAnalysis.runtimeTasks')} -

- -
-
-
- )} -
-
-
- ); -} - -function SummaryItem({ - label, - value, - icon, -}: { - label: string; - value: string; - icon: ReactNode; -}) { - return ( -
-
- {icon} - {label} -
-
{value}
-
- ); -} - -function KeyValueList({ - values, -}: { - values: Record; -}) { - return ( -
- {Object.entries(values).map(([key, value]) => ( -
- {key} - {value ?? '-'} -
- ))} -
- ); -} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 0e502c6d..d41618b0 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index add448c7..cf67c7eb 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 5025a683..69953f7b 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -1245,6 +1245,17 @@ sections: 'ストレージセクション', monitoringTables: '監視テーブル', runtimeTasks: '実行タスク', + cleanupPolicy: 'クリーンアップポリシー', + uploadRetention: 'アップロード保持期間', + logRetention: 'ログ保持期間', + databaseType: 'データベース種別', + days: '日', + exists: '存在', + missing: 'なし', + expiredUploads: '期限切れアップロード', + expiredLogs: '期限切れログ', + noExpiredUploads: '期限切れのアップロードファイルはありません', + noExpiredLogs: '期限切れのログファイルはありません', sectionNames: { database: 'データベース', logs: 'ログ', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 122cfb0b..edf5857d 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -1249,6 +1249,17 @@ const ruRU = { sections: 'Разделы хранилища', monitoringTables: 'Таблицы мониторинга', runtimeTasks: 'Задачи runtime', + cleanupPolicy: 'Политика очистки', + uploadRetention: 'Хранение загрузок', + logRetention: 'Хранение журналов', + databaseType: 'Тип базы данных', + days: 'дн.', + exists: 'Есть', + missing: 'Нет', + expiredUploads: 'Просроченные загрузки', + expiredLogs: 'Просроченные журналы', + noExpiredUploads: 'Нет просроченных загруженных файлов', + noExpiredLogs: 'Нет просроченных журналов', sectionNames: { database: 'База данных', logs: 'Журналы', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 2c3a78a8..fecd58ad 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -1220,6 +1220,17 @@ const thTH = { sections: 'ส่วนพื้นที่จัดเก็บ', monitoringTables: 'ตารางการตรวจสอบ', runtimeTasks: 'งาน runtime', + cleanupPolicy: 'นโยบายการล้างข้อมูล', + uploadRetention: 'ระยะเวลาเก็บไฟล์อัปโหลด', + logRetention: 'ระยะเวลาเก็บบันทึก', + databaseType: 'ชนิดฐานข้อมูล', + days: 'วัน', + exists: 'มีอยู่', + missing: 'ไม่มี', + expiredUploads: 'ไฟล์อัปโหลดที่หมดอายุ', + expiredLogs: 'บันทึกที่หมดอายุ', + noExpiredUploads: 'ไม่มีไฟล์อัปโหลดที่หมดอายุ', + noExpiredLogs: 'ไม่มีบันทึกที่หมดอายุ', sectionNames: { database: 'ฐานข้อมูล', logs: 'บันทึก', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 155dbe7d..cc0df12e 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -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ý', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index e1dd2361..2cebb719 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -1186,6 +1186,17 @@ const zhHans = { sections: '存储分区', monitoringTables: '监控表', runtimeTasks: '运行任务', + cleanupPolicy: '清理策略', + uploadRetention: '上传文件保留', + logRetention: '日志保留', + databaseType: '数据库类型', + days: '天', + exists: '存在', + missing: '不存在', + expiredUploads: '过期上传文件', + expiredLogs: '过期日志', + noExpiredUploads: '暂无过期上传文件', + noExpiredLogs: '暂无过期日志', sectionNames: { database: '数据库', logs: '日志', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 7396100e..de8496e7 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -1186,6 +1186,17 @@ const zhHant = { sections: '儲存分區', monitoringTables: '監控表', runtimeTasks: '執行任務', + cleanupPolicy: '清理策略', + uploadRetention: '上傳檔案保留', + logRetention: '日誌保留', + databaseType: '資料庫類型', + days: '天', + exists: '存在', + missing: '不存在', + expiredUploads: '過期上傳檔案', + expiredLogs: '過期日誌', + noExpiredUploads: '暫無過期上傳檔案', + noExpiredLogs: '暫無過期日誌', sectionNames: { database: '資料庫', logs: '日誌',