diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/storage/page.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/storage/page.tsx new file mode 100644 index 0000000..3b56eb5 --- /dev/null +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(settings)/settings/(workspace)/storage/page.tsx @@ -0,0 +1,13 @@ +import { redirect } from "react-router"; +import type { Route } from "./+types/page"; + +export function clientLoader({ params }: Route.ClientLoaderArgs) { + const { workspaceSlug } = params; + throw redirect(`/${workspaceSlug}/?workspaceSettings=storage`); +} + +function StorageWorkspaceSettingsPage() { + return null; +} + +export default StorageWorkspaceSettingsPage; diff --git a/plane-src/apps/web/app/routes/core.ts b/plane-src/apps/web/app/routes/core.ts index a3f049c..929da63 100644 --- a/plane-src/apps/web/app/routes/core.ts +++ b/plane-src/apps/web/app/routes/core.ts @@ -281,6 +281,10 @@ export const coreRoutes: RouteConfigEntry[] = [ ":workspaceSlug/settings/exports", "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/exports/page.tsx" ), + route( + ":workspaceSlug/settings/storage", + "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/storage/page.tsx" + ), route( ":workspaceSlug/settings/webhooks", "./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/page.tsx" diff --git a/plane-src/apps/web/core/components/navigation/app-rail-root.tsx b/plane-src/apps/web/core/components/navigation/app-rail-root.tsx index 9b8ee2c..a388470 100644 --- a/plane-src/apps/web/core/components/navigation/app-rail-root.tsx +++ b/plane-src/apps/web/core/components/navigation/app-rail-root.tsx @@ -15,6 +15,7 @@ import { cn } from "@plane/utils"; // components import { AppSidebarItem } from "@/components/sidebar/sidebar-item"; import { + getWorkspaceSettingsModalTabFromSearch, openWorkspaceSettingsModal, WORKSPACE_SETTINGS_MODAL_EVENT, } from "@/components/workspace/settings/workspace-settings-modal.utils"; @@ -35,13 +36,13 @@ export const AppRailRoot = observer(() => { const { preferences, updateDisplayMode } = useAppRailPreferences(); const { isCollapsed, toggleAppRail } = useAppRailVisibility(); const [isWorkspaceSettingsModalOpen, setIsWorkspaceSettingsModalOpen] = useState( - searchParams?.get("workspaceSettings") === "general" || searchParams?.get("workspaceSettings") === "ai-voice-tasker" + Boolean(getWorkspaceSettingsModalTabFromSearch(searchParams?.toString() ?? "")) ); // derived values + const workspaceSettingsModalTab = getWorkspaceSettingsModalTabFromSearch(searchParams?.toString() ?? ""); const isWorkspaceSettingsPath = (pathname.includes(`/${workspaceSlug}/settings`) && !projectId) || - searchParams?.get("workspaceSettings") === "general" || - searchParams?.get("workspaceSettings") === "ai-voice-tasker" || + Boolean(workspaceSettingsModalTab) || isWorkspaceSettingsModalOpen; const showLabel = preferences.displayMode === "icon_with_label"; const railWidth = showLabel ? "3.75rem" : "3rem"; diff --git a/plane-src/apps/web/core/components/settings/workspace/sidebar/item-icon.tsx b/plane-src/apps/web/core/components/settings/workspace/sidebar/item-icon.tsx index 8dc3155..54e484d 100644 --- a/plane-src/apps/web/core/components/settings/workspace/sidebar/item-icon.tsx +++ b/plane-src/apps/web/core/components/settings/workspace/sidebar/item-icon.tsx @@ -5,7 +5,7 @@ */ import type { LucideIcon } from "lucide-react"; -import { ArrowUpToLine, Building, CreditCard, Mic, Users, Webhook } from "lucide-react"; +import { ArrowUpToLine, Building, CreditCard, Database, Mic, Users, Webhook } from "lucide-react"; // plane imports import type { ISvgIcons } from "@plane/propel/icons"; import type { TWorkspaceSettingsTabs } from "@plane/types"; @@ -15,6 +15,7 @@ export const WORKSPACE_SETTINGS_ICONS: Record { + const bytes = Number(value || 0); + if (bytes <= 0) return "0 Б"; + + const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"]; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const size = bytes / 1024 ** index; + + return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`; +}; + +const formatCount = (value: number) => new Intl.NumberFormat("ru-RU").format(Number(value || 0)); + +const StatCard = (props: { + title: string; + value: string; + caption: string; + icon: ElementType; + tone?: "default" | "accent" | "warning"; +}) => { + const { title, value, caption, icon: Icon, tone = "default" } = props; + + return ( +
+
+
{title}
+
+ +
+
+
{value}
+
{caption}
+
+ ); +}; + +const ProjectStorageRow = (props: { project: IWorkspaceStorageProjectSummary; maxSize: number }) => { + const { project, maxSize } = props; + const ratio = maxSize > 0 ? Math.max((project.logical_size / maxSize) * 100, project.logical_size > 0 ? 3 : 0) : 0; + + return ( + + +
+ {project.name} + {project.identifier} +
+ + {formatCount(project.file_count)} + {formatCount(project.blob_count)} + +
+
+
+
+ {formatBytes(project.logical_size)} +
+ + {formatBytes(project.physical_size)} + {formatBytes(project.dedup_savings)} + {formatCount(project.failed_upload_count)} + {formatCount(project.soft_deleted_count)} + + ); +}; + +type TStorageSettingsContentProps = { + workspaceSlug: string; +}; + +export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsContentProps) { + const { data, error, isLoading } = useSWR( + workspaceSlug ? ["workspace-storage-summary", workspaceSlug] : null, + ([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug) + ); + + const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size); + const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0); + + return ( +
+
+

Хранилище

+

+ Контроль объема файлов, дедупликации и кандидатов на очистку по workspace и проектам. +

+
+ + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ )} + + {error && ( +
+ Не удалось загрузить данные хранилища. +
+ )} + + {data && ( + <> +
+ + + + 0 ? "warning" : "default"} + /> +
+ +
+ + + +
+ +
+
+
+

Проекты

+

Сортировка по логическому объему файлов.

+
+
+ {formatCount(projects.length)} проектов +
+
+ +
+ + + + + + + + + + + + + + + {projects.map((project) => ( + + ))} + +
ПроектФайлыBlobЛогический объемФизическийДедупОшибкиУдалено
+
+
+ + )} +
+ ); +} diff --git a/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx index df0746d..78931bd 100644 --- a/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx +++ b/plane-src/apps/web/core/components/workspace/settings/workspace-settings-modal.tsx @@ -23,6 +23,7 @@ import { SettingsSidebarItem } from "@/components/settings/sidebar/item"; import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon"; import { WorkspaceSettingsSidebarHeader } from "@/components/settings/workspace/sidebar/header"; import { AIVoiceTaskerSettingsContent } from "@/components/workspace/settings/ai-voice-tasker-settings"; +import { StorageSettingsContent } from "@/components/workspace/settings/storage-settings"; import { WorkspaceDetails } from "@/components/workspace/settings/workspace-details"; // hooks import { useUserPermissions } from "@/hooks/store/user"; @@ -37,7 +38,7 @@ import { } from "./workspace-settings-modal.utils"; const HIDDEN_WORKSPACE_SETTINGS_KEYS = new Set(["billing-and-plans"]); -const MODAL_TABS = new Set(["general", "ai-voice-tasker"]); +const MODAL_TABS = new Set(["general", "storage", "ai-voice-tasker"]); const getInitialTab = (): TWorkspaceSettingsModalTab => { if (typeof window === "undefined") return "general"; @@ -103,9 +104,16 @@ export const WorkspaceSettingsModal = observer(function WorkspaceSettingsModal() return ; } + if (activeTab === "storage" && currentWorkspace?.slug) { + return ; + } + return ; }; + const activeTabLabel = + activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : activeTab === "storage" ? "хранилище" : "основные параметры"; + return (
Настройки workspace
- {currentWorkspace?.name ?? "Workspace"} / {activeTab === "ai-voice-tasker" ? "AI / Voice Tasker" : "основные параметры"} + {currentWorkspace?.name ?? "Workspace"} / {activeTabLabel}