ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: очистка хранилища и проектные квоты
This commit is contained in:
+34
-5
@@ -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
|
||||
)}.`;
|
||||
};
|
||||
|
||||
@@ -27,7 +27,10 @@ import type {
|
||||
IWorkspaceSidebarNavigationItem,
|
||||
IWorkspaceSidebarNavigation,
|
||||
IWorkspaceUserPropertiesResponse,
|
||||
IWorkspaceStorageMaintenanceResponse,
|
||||
IWorkspaceStorageProjectQuotaResponse,
|
||||
IWorkspaceStorageSummaryResponse,
|
||||
TWorkspaceStorageMaintenanceAction,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
@@ -61,6 +64,29 @@ export class WorkspaceService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async runWorkspaceStorageMaintenance(
|
||||
workspaceSlug: string,
|
||||
action: TWorkspaceStorageMaintenanceAction
|
||||
): Promise<IWorkspaceStorageMaintenanceResponse> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/storage/maintenance/`, { action })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspaceStorageProjectQuota(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: { quota_enabled: boolean; quota: number }
|
||||
): Promise<IWorkspaceStorageProjectQuotaResponse> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/storage/projects/${projectId}/quota/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async createWorkspace(data: Partial<IWorkspace>): Promise<IWorkspace> {
|
||||
return this.post("/api/workspaces/", data)
|
||||
.then((response) => response?.data)
|
||||
|
||||
Reference in New Issue
Block a user