ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: очистка хранилища и проектные квоты

This commit is contained in:
DCCONSTRUCTIONS
2026-04-28 13:25:37 +03:00
parent 02d79da6f9
commit 46e27a326c
15 changed files with 870 additions and 66 deletions
@@ -27,6 +27,35 @@ export type TAttachmentHelpers = {
snapshot: TAttachmentSnapshot;
};
const formatBytes = (value?: number) => {
const bytes = Number(value || 0);
if (bytes <= 0) return "0 Б";
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const normalizedValue = bytes / 1024 ** unitIndex;
return `${normalizedValue.toLocaleString("ru-RU", {
maximumFractionDigits: normalizedValue >= 10 ? 0 : 1,
})} ${units[unitIndex]}`;
};
const getAttachmentUploadErrorMessage = (error: unknown) => {
if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "project_storage_quota_exceeded"
) {
const remaining = "remaining" in error && typeof error.remaining === "number" ? error.remaining : 0;
const quota = "quota" in error && typeof error.quota === "number" ? error.quota : 0;
return `Квота проекта превышена. Доступно ${formatBytes(remaining)} из лимита ${formatBytes(quota)}. Файл не был загружен.`;
}
return "Вложение не удалось загрузить.";
};
export const useAttachmentOperations = (
workspaceSlug: string,
projectId: string,
@@ -43,14 +72,14 @@ export const useAttachmentOperations = (
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...",
loading: "Загружаем вложение...",
success: {
title: "Attachment uploaded",
message: () => "The attachment has been successfully uploaded",
title: "Вложение загружено",
message: () => "Файл успешно добавлен к рабочему элементу.",
},
error: {
title: "Attachment not uploaded",
message: () => "The attachment could not be uploaded",
title: "Вложение не загружено",
message: getAttachmentUploadErrorMessage,
},
});
@@ -4,11 +4,26 @@
* See the LICENSE file for details.
*/
import { AlertTriangle, Database, Files, HardDrive, Layers3, Recycle, UploadCloud } from "lucide-react";
import {
AlertTriangle,
Check,
Database,
Files,
HardDrive,
Layers3,
Recycle,
RotateCw,
SearchCheck,
Trash2,
UploadCloud,
} from "lucide-react";
import type { ElementType } from "react";
import { useEffect, useState } from "react";
import useSWR from "swr";
// plane imports
import type { IWorkspaceStorageProjectSummary } from "@plane/types";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IWorkspaceStorageProjectSummary, TWorkspaceStorageMaintenanceAction } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { SettingsHeading } from "@/components/settings/heading";
@@ -16,6 +31,7 @@ import { SettingsHeading } from "@/components/settings/heading";
import { WorkspaceService } from "@/services/workspace.service";
const workspaceService = new WorkspaceService();
const MEGABYTE = 1024 * 1024;
const formatBytes = (value: number) => {
const bytes = Number(value || 0);
@@ -30,6 +46,10 @@ const formatBytes = (value: number) => {
const formatCount = (value: number) => new Intl.NumberFormat("ru-RU").format(Number(value || 0));
const bytesToMegabytes = (value: number) => Math.max(Math.ceil(Number(value || 0) / MEGABYTE), 0);
const megabytesToBytes = (value: string | number) => Math.max(Math.round(Number(value || 0) * MEGABYTE), 0);
const StatCard = (props: {
title: string;
value: string;
@@ -59,12 +79,17 @@ const StatCard = (props: {
);
};
const ProjectStorageRow = (props: { project: IWorkspaceStorageProjectSummary; maxSize: number }) => {
const { project, maxSize } = props;
const ProjectStorageRow = (props: {
maxSize: number;
onRefresh: () => Promise<unknown>;
project: IWorkspaceStorageProjectSummary;
workspaceSlug: string;
}) => {
const { maxSize, onRefresh, project, workspaceSlug } = props;
const ratio = maxSize > 0 ? Math.max((project.logical_size / maxSize) * 100, project.logical_size > 0 ? 3 : 0) : 0;
return (
<div className="nodedc-settings-field grid min-w-[62rem] grid-cols-[minmax(14rem,1.35fr)_0.55fr_0.55fr_minmax(15rem,1.45fr)_0.75fr_0.75fr_0.65fr_0.65fr] items-center gap-4 px-4 py-3.5">
<div className="nodedc-settings-field grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] items-center gap-4 px-4 py-3.5">
<div className="flex min-w-0 flex-col">
<span className="truncate text-14 font-semibold text-primary">{project.name}</span>
<span className="mt-1 text-11 uppercase tracking-[0.16em] text-tertiary">{project.identifier}</span>
@@ -79,8 +104,10 @@ const ProjectStorageRow = (props: { project: IWorkspaceStorageProjectSummary; ma
</div>
<StorageValue>{formatBytes(project.physical_size)}</StorageValue>
<StorageValue accent>{formatBytes(project.dedup_savings)}</StorageValue>
<ProjectQuotaControl project={project} workspaceSlug={workspaceSlug} onRefresh={onRefresh} />
<StorageValue warning={project.failed_upload_count > 0}>{formatCount(project.failed_upload_count)}</StorageValue>
<StorageValue>{formatCount(project.soft_deleted_count)}</StorageValue>
<StorageValue warning={project.stale_unuploaded_count > 0}>{formatCount(project.stale_unuploaded_count)}</StorageValue>
<StorageValue warning={project.soft_deleted_count > 0}>{formatCount(project.soft_deleted_count)}</StorageValue>
</div>
);
};
@@ -98,24 +125,177 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
);
const ProjectStorageHeader = () => (
<div className="grid min-w-[62rem] grid-cols-[minmax(14rem,1.35fr)_0.55fr_0.55fr_minmax(15rem,1.45fr)_0.75fr_0.75fr_0.65fr_0.65fr] gap-4 px-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
<div className="grid min-w-[78rem] grid-cols-[minmax(14rem,1.35fr)_0.48fr_0.48fr_minmax(14rem,1.25fr)_0.7fr_0.7fr_minmax(14rem,1.2fr)_0.6fr_0.6fr_0.6fr] gap-4 px-4 text-[11px] font-semibold uppercase tracking-[0.16em] text-tertiary">
<span>Проект</span>
<span>Файлы</span>
<span>Blob</span>
<span>Логический объем</span>
<span>Физический</span>
<span>Дедуп</span>
<span>Квота</span>
<span>Ошибки</span>
<span>Зависшие</span>
<span>Удалено</span>
</div>
);
const ProjectQuotaControl = (props: {
onRefresh: () => Promise<unknown>;
project: IWorkspaceStorageProjectSummary;
workspaceSlug: string;
}) => {
const { onRefresh, project, workspaceSlug } = props;
const [isSaving, setIsSaving] = useState(false);
const [isEnabled, setIsEnabled] = useState(project.quota_enabled);
const [quotaMb, setQuotaMb] = useState(() => String(bytesToMegabytes(project.quota || project.logical_size)));
useEffect(() => {
setIsEnabled(project.quota_enabled);
setQuotaMb(String(bytesToMegabytes(project.quota || project.logical_size)));
}, [project.logical_size, project.quota, project.quota_enabled]);
const currentQuota = megabytesToBytes(quotaMb);
const normalizedQuota = isEnabled ? Math.max(currentQuota, MEGABYTE) : currentQuota;
const hasChanges =
isEnabled !== project.quota_enabled ||
(isEnabled && normalizedQuota !== (project.quota_enabled ? Math.max(project.quota || 0, MEGABYTE) : 0));
const saveQuota = async () => {
setIsSaving(true);
try {
await workspaceService.updateWorkspaceStorageProjectQuota(workspaceSlug, project.id, {
quota_enabled: isEnabled,
quota: normalizedQuota,
});
await onRefresh();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Квота обновлена",
message: isEnabled ? "Лимит проекта сохранен." : "Лимит проекта отключен.",
});
} catch (err) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Квота не сохранена",
message: "Не удалось обновить лимит проекта.",
});
} finally {
setIsSaving(false);
}
};
return (
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className={cn(
"grid size-7 shrink-0 place-items-center rounded-full bg-white/6 text-secondary transition hover:bg-white/10",
isEnabled && "bg-accent-primary text-[rgb(var(--nodedc-on-accent-rgb))]",
(project.quota_warning || project.quota_exceeded) && "bg-red-500/20 text-red-200"
)}
disabled={isSaving}
onClick={() => setIsEnabled((value) => !value)}
title={isEnabled ? "Отключить квоту" : "Включить квоту"}
>
<Database className="size-3.5" />
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 rounded-full bg-white/6 px-3 py-1.5">
<input
className="min-w-0 flex-1 bg-transparent text-12 font-medium text-primary outline-none placeholder:text-tertiary"
disabled={isSaving}
inputMode="numeric"
min={1}
onChange={(event) => setQuotaMb(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
saveQuota();
}
}}
type="number"
value={quotaMb}
/>
<span className="shrink-0 text-11 font-medium text-tertiary">МБ</span>
</div>
<div
className={cn(
"mt-1 truncate text-[11px] text-tertiary",
project.quota_warning && "text-yellow-200",
project.quota_exceeded && "text-red-200"
)}
>
{project.quota_enabled
? `${formatBytes(project.quota_used)} из ${formatBytes(project.quota)}`
: "без лимита"}
</div>
</div>
<button
type="button"
className={cn(
"grid size-7 shrink-0 place-items-center rounded-full bg-white/6 text-secondary transition hover:bg-white/10",
hasChanges && "bg-accent-primary text-[rgb(var(--nodedc-on-accent-rgb))]"
)}
disabled={isSaving || !hasChanges}
onClick={saveQuota}
title="Применить изменения квоты"
>
<Check className="size-3.5" />
</button>
</div>
);
};
type TMaintenanceCardProps = {
action: TWorkspaceStorageMaintenanceAction;
caption: string;
disabled?: boolean;
icon: ElementType;
isRunning: boolean;
onRun: (action: TWorkspaceStorageMaintenanceAction) => void;
title: string;
value: string;
};
const MaintenanceCard = ({
action,
caption,
disabled = false,
icon: Icon,
isRunning,
onRun,
title,
value,
}: TMaintenanceCardProps) => (
<div className="nodedc-settings-card flex min-h-40 flex-col justify-between gap-5 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-tertiary">{title}</div>
<div className="mt-3 text-2xl font-semibold tracking-normal text-primary">{value}</div>
<div className="mt-2 text-12 leading-5 text-secondary">{caption}</div>
</div>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5 text-secondary">
<Icon className="size-4" />
</div>
</div>
<Button
variant="ghost"
size="sm"
disabled={disabled || isRunning}
onClick={() => onRun(action)}
className="w-fit rounded-full border-0 bg-white/7 px-4 text-12 text-primary hover:bg-white/10"
>
{isRunning ? "Выполняем..." : "Запустить"}
</Button>
</div>
);
type TStorageSettingsContentProps = {
workspaceSlug: string;
};
export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsContentProps) {
const { data, error, isLoading } = useSWR(
const [runningAction, setRunningAction] = useState<TWorkspaceStorageMaintenanceAction | null>(null);
const { data, error, isLoading, mutate } = useSWR(
workspaceSlug ? ["workspace-storage-summary", workspaceSlug] : null,
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug)
);
@@ -123,6 +303,27 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
const projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size);
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0);
const runMaintenance = async (action: TWorkspaceStorageMaintenanceAction) => {
setRunningAction(action);
try {
const result = await workspaceService.runWorkspaceStorageMaintenance(workspaceSlug, action);
await mutate();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Хранилище обновлено",
message: getMaintenanceResultMessage(result.action, result),
});
} catch (err) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Действие не выполнено",
message: "Не удалось выполнить обслуживание хранилища.",
});
} finally {
setRunningAction(null);
}
};
return (
<div className="flex w-full flex-col gap-7">
<SettingsHeading
@@ -176,7 +377,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
/>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard
title="Зависшие загрузки"
value={formatCount(data.diagnostics.stale_unuploaded_count)}
@@ -195,8 +396,77 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
caption={`${formatBytes(data.diagnostics.orphaned_blob_size + data.diagnostics.missing_blob_size)} вне активных ссылок`}
icon={Database}
/>
<StatCard
title="Сбои preview"
value={formatCount(data.diagnostics.failed_preview_count)}
caption={`${formatBytes(data.diagnostics.failed_preview_size)} с последней ошибкой предпросмотра`}
icon={AlertTriangle}
tone={data.diagnostics.failed_preview_count > 0 ? "warning" : "default"}
/>
<StatCard
title="Квоты проектов"
value={`${formatCount(data.diagnostics.quota_exceeded_project_count)} / ${formatCount(data.diagnostics.quota_warning_project_count)}`}
caption="Превышены / близко к лимиту"
icon={HardDrive}
tone={data.diagnostics.quota_exceeded_project_count > 0 ? "warning" : "default"}
/>
</div>
<section>
<div className="mb-4 flex items-end justify-between gap-4">
<div>
<h2 className="text-lg font-semibold tracking-normal text-primary">Обслуживание</h2>
<p className="mt-1 text-13 text-secondary">
Ручные действия для диагностики и очистки файловой помойки без фонового удаления при открытии.
</p>
</div>
<div className="nodedc-settings-chip flex min-h-0 items-center px-4 py-2 text-12 font-medium text-secondary">
retention {formatCount(data.cleanup.soft_deleted_retention_days)} дн.
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<MaintenanceCard
action="scan_missing_blobs"
title="Скан blob"
value={formatCount(data.diagnostics.missing_blob_count)}
caption="Проверить активные blob в MinIO/S3 и пометить потерянные объекты."
icon={SearchCheck}
isRunning={runningAction === "scan_missing_blobs"}
onRun={runMaintenance}
/>
<MaintenanceCard
action="purge_stale_unuploaded"
title="Зависшие upload"
value={formatCount(data.cleanup.unuploaded_ready_count)}
caption={`${formatBytes(data.cleanup.unuploaded_ready_size)} старше ${formatCount(data.cleanup.unuploaded_retention_days)} дн.`}
icon={UploadCloud}
disabled={data.cleanup.unuploaded_ready_count === 0}
isRunning={runningAction === "purge_stale_unuploaded"}
onRun={runMaintenance}
/>
<MaintenanceCard
action="purge_expired_deleted"
title="Удаленные файлы"
value={formatCount(data.cleanup.soft_deleted_ready_count)}
caption={`${formatBytes(data.cleanup.soft_deleted_ready_size)} прошли retention cleanup.`}
icon={Trash2}
disabled={data.cleanup.soft_deleted_ready_count === 0}
isRunning={runningAction === "purge_expired_deleted"}
onRun={runMaintenance}
/>
<MaintenanceCard
action="purge_orphaned_blobs"
title="Orphan blob"
value={formatCount(data.cleanup.orphaned_blob_ready_count)}
caption={`${formatBytes(data.cleanup.orphaned_blob_ready_size)} без активных ссылок.`}
icon={RotateCw}
disabled={data.cleanup.orphaned_blob_ready_count === 0}
isRunning={runningAction === "purge_orphaned_blobs"}
onRun={runMaintenance}
/>
</div>
</section>
<section className="nodedc-settings-card p-5">
<div className="mb-5 flex items-center justify-between gap-4">
<div>
@@ -209,11 +479,17 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
</div>
<div className="overflow-x-auto">
<div className="flex min-w-[62rem] flex-col gap-2">
<div className="flex min-w-[78rem] flex-col gap-2">
<ProjectStorageHeader />
<div className="flex flex-col gap-2">
{projects.map((project) => (
<ProjectStorageRow key={project.id} project={project} maxSize={maxProjectSize} />
<ProjectStorageRow
key={project.id}
project={project}
maxSize={maxProjectSize}
workspaceSlug={workspaceSlug}
onRefresh={mutate}
/>
))}
</div>
</div>
@@ -224,3 +500,29 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
</div>
);
}
const getMaintenanceResultMessage = (
action: TWorkspaceStorageMaintenanceAction,
result: {
deleted_asset_count?: number;
deleted_blob_count?: number;
deleted_object_count?: number;
missing_count?: number;
restored_count?: number;
scanned_count?: number;
}
) => {
if (action === "scan_missing_blobs")
return `Проверено: ${formatCount(result.scanned_count ?? 0)}, потеряно: ${formatCount(
result.missing_count ?? 0
)}, восстановлено: ${formatCount(result.restored_count ?? 0)}.`;
if (action === "purge_orphaned_blobs")
return `Удалено blob-записей: ${formatCount(result.deleted_blob_count ?? 0)}, объектов: ${formatCount(
result.deleted_object_count ?? 0
)}.`;
return `Удалено файловых записей: ${formatCount(result.deleted_asset_count ?? 0)}, объектов: ${formatCount(
result.deleted_object_count ?? 0
)}.`;
};