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

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

View File

@ -36,6 +36,8 @@ from plane.app.views import (
UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet,
WorkspaceStorageMaintenanceEndpoint,
WorkspaceStorageProjectQuotaEndpoint,
WorkspaceStorageSummaryEndpoint,
WorkspaceUserPreferenceViewSet,
)
@ -263,6 +265,16 @@ urlpatterns = [
WorkspaceStorageSummaryEndpoint.as_view(),
name="workspace-storage-summary",
),
path(
"workspaces/<str:slug>/storage/maintenance/",
WorkspaceStorageMaintenanceEndpoint.as_view(),
name="workspace-storage-maintenance",
),
path(
"workspaces/<str:slug>/storage/projects/<uuid:project_id>/quota/",
WorkspaceStorageProjectQuotaEndpoint.as_view(),
name="workspace-storage-project-quota",
),
# User Preference
path(
"workspaces/<str:slug>/sidebar-preferences/",

View File

@ -83,7 +83,11 @@ from .workspace.module import WorkspaceModulesEndpoint
from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet
from .workspace.storage import WorkspaceStorageSummaryEndpoint
from .workspace.storage import (
WorkspaceStorageMaintenanceEndpoint,
WorkspaceStorageProjectQuotaEndpoint,
WorkspaceStorageSummaryEndpoint,
)
from .state.base import StateViewSet, IntakeStateEndpoint
from .view.base import (

View File

@ -23,7 +23,7 @@ from plane.settings.storage import S3Storage
from plane.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly
from plane.throttles.asset import AssetRateThrottle
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.file_dedup import (
UploadedObjectMissing,
attach_existing_blob_to_file_asset,
@ -361,6 +361,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
if entity_type == FileAsset.EntityTypeContext.PROJECT_COVER and entity_identifier:
quota_project = Project.objects.filter(id=entity_identifier, workspace=workspace).first()
else:
quota_project = None
if quota_project is not None:
quota_response = get_project_storage_quota_response(quota_project, requested_size=size)
if quota_response is not None:
return quota_response
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -564,6 +572,10 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
project = Project.objects.get(id=project_id, workspace=workspace)
quota_response = get_project_storage_quota_response(project, requested_size=size)
if quota_response is not None:
return quota_response
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -576,7 +588,7 @@ class ProjectAssetEndpoint(BaseAPIView):
workspace=workspace,
created_by=request.user,
entity_type=entity_type,
project_id=project_id,
project_id=project.id,
**self.get_entity_id_field(entity_type, entity_identifier),
)
@ -767,10 +779,6 @@ class DuplicateAssetEndpoint(BaseAPIView):
)
workspace = Workspace.objects.get(slug=slug)
if project_id:
# check if project exists in the workspace
if not Project.objects.filter(id=project_id, workspace=workspace).exists():
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(id=asset_id, workspace=workspace, is_uploaded=True).first()
@ -778,6 +786,14 @@ class DuplicateAssetEndpoint(BaseAPIView):
if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
if project_id:
project = Project.objects.filter(id=project_id, workspace=workspace).first()
if not project:
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
quota_response = get_project_storage_quota_response(project, requested_size=original_asset.size)
if quota_response is not None:
return quota_response
destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
duplicated_asset = FileAsset.objects.create(
attributes={

View File

@ -19,12 +19,12 @@ from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer
from plane.db.models import FileAsset, Workspace
from plane.db.models import FileAsset, Project, Workspace
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
from plane.utils.host import base_host
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
from plane.utils.upload_limits import get_project_storage_quota_response, resolve_workspace_upload_size_limit
from plane.utils.attachment_preview import attachment_object_exists, get_attachment_preview_response
from plane.utils.file_dedup import finalize_uploaded_file_asset, release_file_asset_blob, UploadedObjectMissing
@ -118,12 +118,16 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
# Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size)
quota_response = get_project_storage_quota_response(project, requested_size=size)
if quota_response is not None:
return quota_response
# Create a File Asset
asset = FileAsset.objects.create(

View File

@ -2,8 +2,10 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import os
from datetime import timedelta
from django.conf import settings
from django.db.models import Sum
from django.utils import timezone
from rest_framework import status
@ -12,6 +14,8 @@ from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Project, StoredBlob, Workspace
from plane.settings.storage import S3Storage
from plane.utils.file_dedup import hard_delete_file_asset, release_file_asset_blob
def _int_size(value):
@ -33,24 +37,94 @@ def _dedup_savings(logical_size, physical_size):
return max(_int_size(logical_size) - _int_size(physical_size), 0)
def _retention_days(env_key, default):
try:
return max(int(os.environ.get(env_key, default)), 0)
except (TypeError, ValueError):
return int(default)
def _storage_cutoffs():
now = timezone.now()
unuploaded_retention_days = _retention_days("UNUPLOADED_ASSET_DELETE_DAYS", 7)
soft_deleted_retention_days = _retention_days("FILE_ASSET_HARD_DELETE_AFTER_DAYS", settings.HARD_DELETE_AFTER_DAYS)
return {
"stale_upload_cutoff": now - timedelta(days=1),
"unuploaded_cleanup_cutoff": now - timedelta(days=unuploaded_retention_days),
"soft_deleted_cleanup_cutoff": now - timedelta(days=soft_deleted_retention_days),
"unuploaded_retention_days": unuploaded_retention_days,
"soft_deleted_retention_days": soft_deleted_retention_days,
}
def _blob_size_queryset(queryset):
return _int_size(queryset.aggregate(total=Sum("size")).get("total"))
def _blob_size_by_ids(blob_ids):
if not blob_ids:
return 0
return _blob_size_queryset(StoredBlob.objects.filter(id__in=blob_ids, status=StoredBlob.Status.ACTIVE))
def _cleanup_snapshot(workspace, failed_uploads, soft_deleted_assets, orphaned_blobs):
cutoffs = _storage_cutoffs()
unuploaded_ready = failed_uploads.filter(created_at__lt=cutoffs["unuploaded_cleanup_cutoff"])
soft_deleted_ready = soft_deleted_assets.filter(deleted_at__lt=cutoffs["soft_deleted_cleanup_cutoff"])
orphaned_ready = orphaned_blobs.filter(ref_count=0)
return {
"unuploaded_retention_days": cutoffs["unuploaded_retention_days"],
"soft_deleted_retention_days": cutoffs["soft_deleted_retention_days"],
"unuploaded_ready_count": unuploaded_ready.count(),
"unuploaded_ready_size": _sum_asset_size(unuploaded_ready),
"soft_deleted_ready_count": soft_deleted_ready.count(),
"soft_deleted_ready_size": _sum_asset_size(soft_deleted_ready),
"orphaned_blob_ready_count": orphaned_ready.count(),
"orphaned_blob_ready_size": _blob_size_queryset(orphaned_ready),
"workspace_slug": workspace.slug,
}
def _project_quota_snapshot(project, used_size):
enabled = bool(project.storage_quota_enabled and project.storage_quota > 0)
quota = int(project.storage_quota or 0) if enabled else 0
used = int(used_size or 0)
remaining = max(quota - used, 0) if enabled else None
percent = round((used / quota) * 100, 1) if quota > 0 else 0
return {
"quota_enabled": enabled,
"quota": quota,
"quota_used": used,
"quota_remaining": remaining,
"quota_percent": percent,
"quota_exceeded": enabled and used > quota,
"quota_warning": enabled and used <= quota and percent >= 80,
}
class WorkspaceStorageSummaryEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
stale_cutoff = timezone.now() - timedelta(days=1)
cutoffs = _storage_cutoffs()
active_assets = FileAsset.objects.filter(workspace=workspace, is_uploaded=True)
failed_preview_assets = active_assets.filter(attributes__preview_status="failed")
active_blob_ids = list(active_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct())
workspace_logical_size = _sum_asset_size(active_assets)
workspace_physical_size = _sum_blob_size(active_blob_ids)
workspace_physical_size = _blob_size_by_ids(active_blob_ids)
failed_uploads = FileAsset.all_objects.filter(
workspace=workspace,
is_uploaded=False,
deleted_at__isnull=True,
)
stale_unuploaded = failed_uploads.filter(created_at__lt=stale_cutoff)
stale_unuploaded = failed_uploads.filter(created_at__lt=cutoffs["stale_upload_cutoff"])
soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False)
orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED)
missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING)
@ -64,8 +138,9 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
project_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()
)
project_logical_size = _sum_asset_size(project_assets)
project_physical_size = _sum_blob_size(project_blob_ids)
project_physical_size = _blob_size_by_ids(project_blob_ids)
project_failed_uploads = failed_uploads.filter(project=project)
project_failed_previews = failed_preview_assets.filter(project=project)
project_soft_deleted = soft_deleted_assets.filter(project=project)
project_uploaded_without_blob = uploaded_without_blob.filter(project=project)
@ -81,9 +156,15 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
"dedup_savings": _dedup_savings(project_logical_size, project_physical_size),
"failed_upload_count": project_failed_uploads.count(),
"failed_upload_size": _sum_asset_size(project_failed_uploads),
"failed_preview_count": project_failed_previews.count(),
"failed_preview_size": _sum_asset_size(project_failed_previews),
"stale_unuploaded_count": project_failed_uploads.filter(
created_at__lt=cutoffs["stale_upload_cutoff"]
).count(),
"soft_deleted_count": project_soft_deleted.count(),
"soft_deleted_size": _sum_asset_size(project_soft_deleted),
"uploaded_without_blob_count": project_uploaded_without_blob.count(),
**_project_quota_snapshot(project, project_logical_size),
}
)
@ -114,8 +195,160 @@ class WorkspaceStorageSummaryEndpoint(BaseAPIView):
"orphaned_blob_size": _sum_blob_size(list(orphaned_blobs.values_list("id", flat=True))),
"missing_blob_count": missing_blobs.count(),
"missing_blob_size": _sum_blob_size(list(missing_blobs.values_list("id", flat=True))),
"failed_preview_count": failed_preview_assets.count(),
"failed_preview_size": _sum_asset_size(failed_preview_assets),
"failed_preview_instrumented": True,
"quota_warning_project_count": sum(1 for project in project_rows if project["quota_warning"]),
"quota_exceeded_project_count": sum(1 for project in project_rows if project["quota_exceeded"]),
},
"cleanup": _cleanup_snapshot(workspace, failed_uploads, soft_deleted_assets, orphaned_blobs),
"projects": project_rows,
}
return Response(data, status=status.HTTP_200_OK)
class WorkspaceStorageProjectQuotaEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def patch(self, request, slug, project_id):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
enabled = bool(request.data.get("quota_enabled", False))
try:
quota = int(request.data.get("quota", 0) or 0)
except (TypeError, ValueError):
quota = 0
project.storage_quota_enabled = enabled and quota > 0
project.storage_quota = max(quota, 0)
project.save(update_fields=["storage_quota_enabled", "storage_quota", "updated_at"])
return Response(
{
"id": str(project.id),
**_project_quota_snapshot(
project,
_sum_asset_size(FileAsset.objects.filter(project=project, is_uploaded=True)),
),
},
status=status.HTTP_200_OK,
)
class WorkspaceStorageMaintenanceEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
action = request.data.get("action")
if action == "scan_missing_blobs":
return Response(self._scan_missing_blobs(request, workspace), status=status.HTTP_200_OK)
if action == "purge_stale_unuploaded":
return Response(self._purge_stale_unuploaded(request, workspace), status=status.HTTP_200_OK)
if action == "purge_expired_deleted":
return Response(self._purge_expired_deleted(request, workspace), status=status.HTTP_200_OK)
if action == "purge_orphaned_blobs":
return Response(self._purge_orphaned_blobs(request, workspace), status=status.HTTP_200_OK)
return Response({"error": "Unsupported storage maintenance action."}, status=status.HTTP_400_BAD_REQUEST)
def _scan_missing_blobs(self, request, workspace):
storage = S3Storage(request=request, is_server=True)
blobs = StoredBlob.objects.filter(
workspace=workspace,
status__in=[StoredBlob.Status.ACTIVE, StoredBlob.Status.MISSING],
)
scanned = 0
missing = 0
restored = 0
for blob in blobs.iterator():
scanned += 1
metadata = storage.get_object_metadata(object_name=blob.canonical_object_key)
if metadata:
if blob.status == StoredBlob.Status.MISSING:
restored += 1
blob.status = StoredBlob.Status.ACTIVE
blob.storage_metadata = metadata
blob.save(update_fields=["status", "storage_metadata", "updated_at"])
continue
if blob.status != StoredBlob.Status.MISSING:
missing += 1
blob.status = StoredBlob.Status.MISSING
blob.save(update_fields=["status", "updated_at"])
return {
"action": "scan_missing_blobs",
"scanned_count": scanned,
"missing_count": missing,
"restored_count": restored,
}
def _purge_stale_unuploaded(self, request, workspace):
cutoff = _storage_cutoffs()["unuploaded_cleanup_cutoff"]
assets = FileAsset.objects.filter(workspace=workspace, is_uploaded=False, created_at__lt=cutoff)
deleted_assets = 0
deleted_objects = 0
released_size = 0
for asset in assets.iterator():
released_size += _int_size(asset.size)
release_result = release_file_asset_blob(asset, request=request, delete_untracked_object=True)
if release_result.deleted_object:
deleted_objects += 1
asset.delete(soft=False)
deleted_assets += 1
return {
"action": "purge_stale_unuploaded",
"deleted_asset_count": deleted_assets,
"deleted_object_count": deleted_objects,
"released_size": released_size,
}
def _purge_expired_deleted(self, request, workspace):
cutoff = _storage_cutoffs()["soft_deleted_cleanup_cutoff"]
assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__lt=cutoff)
storage = S3Storage(request=request, is_server=True)
deleted_assets = 0
deleted_objects = 0
released_size = 0
for asset in assets.iterator():
released_size += _int_size(asset.size)
if hard_delete_file_asset(asset, request=request, storage=storage):
deleted_objects += 1
deleted_assets += 1
return {
"action": "purge_expired_deleted",
"deleted_asset_count": deleted_assets,
"deleted_object_count": deleted_objects,
"released_size": released_size,
}
def _purge_orphaned_blobs(self, request, workspace):
storage = S3Storage(request=request, is_server=True)
blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED, ref_count=0)
deleted_blobs = 0
deleted_objects = 0
released_size = 0
for blob in blobs.iterator():
released_size += _int_size(blob.size)
if storage.delete_files(object_names=[blob.canonical_object_key]):
deleted_objects += 1
blob.delete(soft=False)
deleted_blobs += 1
return {
"action": "purge_orphaned_blobs",
"deleted_blob_count": deleted_blobs,
"deleted_object_count": deleted_objects,
"released_size": released_size,
}

View File

@ -15,42 +15,9 @@ from django.db.models import Q
from celery import shared_task
# Module imports
from plane.db.models import FileAsset, StoredBlob
from plane.db.models import FileAsset
from plane.settings.storage import S3Storage
from plane.utils.file_dedup import release_file_asset_blob
def _asset_object_key(asset):
return str(asset.asset.name or asset.asset)
def _delete_legacy_asset_object(asset, storage):
object_key = _asset_object_key(asset)
if not object_key:
return False
has_other_reference = FileAsset.all_objects.filter(asset=object_key).exclude(pk=asset.pk).exists()
if has_other_reference:
return False
return storage.delete_files(object_names=[object_key])
def _hard_delete_file_asset(asset, storage=None):
blob_id = asset.blob_id
if blob_id:
release_file_asset_blob(asset)
StoredBlob.all_objects.filter(
pk=blob_id,
ref_count=0,
status=StoredBlob.Status.ORPHANED,
).delete()
else:
storage = storage or S3Storage()
_delete_legacy_asset_object(asset, storage)
asset.delete(soft=False)
from plane.utils.file_dedup import hard_delete_file_asset, release_file_asset_blob
@shared_task
@ -74,4 +41,4 @@ def delete_expired_file_asset():
expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff)
for asset in expired_assets.iterator():
_hard_delete_file_asset(asset, storage=storage)
hard_delete_file_asset(asset, storage=storage)

View File

@ -0,0 +1,22 @@
# Generated by Plane on 2026-04-28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("db", "0129_workspace_ai_access_scope"),
]
operations = [
migrations.AddField(
model_name="project",
name="storage_quota_enabled",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="project",
name="storage_quota",
field=models.PositiveBigIntegerField(default=0),
),
]

View File

@ -98,6 +98,8 @@ class Project(BaseModel):
is_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = models.BooleanField(default=False)
guest_view_all_features = models.BooleanField(default=False)
storage_quota_enabled = models.BooleanField(default=False)
storage_quota = models.PositiveBigIntegerField(default=0)
cover_image = models.TextField(blank=True, null=True)
cover_image_asset = models.ForeignKey(
"db.FileAsset",

View File

@ -6,10 +6,28 @@ from urllib.parse import quote
from botocore.exceptions import ClientError
from django.http import HttpResponse, StreamingHttpResponse
from django.utils import timezone
from plane.settings.storage import S3Storage
def _mark_preview_state(asset, status, error=None):
attributes = dict(asset.attributes or {})
if status == "failed":
attributes["preview_status"] = "failed"
attributes["preview_failed_at"] = timezone.now().isoformat()
if error:
attributes["preview_error"] = str(error)
else:
attributes.pop("preview_failed_at", None)
attributes.pop("preview_error", None)
attributes["preview_status"] = "ok"
asset.attributes = attributes
asset.save(update_fields=["attributes", "updated_at"])
def attachment_object_exists(asset):
storage = S3Storage(request=None)
try:
@ -34,7 +52,11 @@ def get_attachment_preview_response(request, asset, disposition="inline"):
try:
storage_response = storage.s3_client.get_object(**request_kwargs)
except ClientError:
if disposition == "inline" and asset.attributes.get("preview_status") == "failed":
_mark_preview_state(asset, "ok")
except ClientError as exc:
if disposition == "inline":
_mark_preview_state(asset, "failed", error=exc.response.get("Error", {}).get("Code", exc))
return HttpResponse("Attachment object not found.", status=404)
content_type = (

View File

@ -277,6 +277,46 @@ def release_file_asset_blob(
return FileBlobReleaseResult(had_blob=had_blob, deleted_object=deleted_object, object_key=delete_object_key)
def _delete_legacy_asset_object(asset: FileAsset, storage: S3Storage) -> bool:
object_key = _object_key(asset)
if not object_key:
return False
has_other_reference = FileAsset.all_objects.filter(asset=object_key).exclude(pk=asset.pk).exists()
if has_other_reference:
return False
return storage.delete_files(object_names=[object_key])
def hard_delete_file_asset(asset: FileAsset, request=None, storage: S3Storage | None = None) -> bool:
"""
Permanently remove a FileAsset row and release its storage reference.
Blob-backed assets release the StoredBlob ref-count. Legacy non-blob assets
are physically deleted only when no other FileAsset row still points at the
same object key.
"""
storage = storage or _server_storage(request=request)
blob_id = asset.blob_id
deleted_object = False
if blob_id:
release_result = release_file_asset_blob(asset, request=request)
deleted_object = release_result.deleted_object
StoredBlob.all_objects.filter(
pk=blob_id,
ref_count=0,
status=StoredBlob.Status.ORPHANED,
).delete()
else:
deleted_object = _delete_legacy_asset_object(asset, storage)
asset.delete(soft=False)
return deleted_object
def attach_existing_blob_to_file_asset(source: FileAsset, target: FileAsset) -> bool:
if not source.blob_id:
source.refresh_from_db(fields=["blob"])

View File

@ -1,4 +1,7 @@
from django.conf import settings
from django.db.models import Sum
from rest_framework import status
from rest_framework.response import Response
def get_workspace_file_size_limit(workspace):
@ -22,3 +25,71 @@ def resolve_workspace_upload_size_limit(workspace, requested_size):
return requested_size
return min(requested_size, workspace_limit)
def get_project_storage_quota(project):
if not getattr(project, "storage_quota_enabled", False):
return None
quota = getattr(project, "storage_quota", 0) or 0
return max(int(quota), 0) or None
def get_project_storage_usage(project):
from plane.db.models import FileAsset
total = (
FileAsset.objects.filter(
workspace_id=project.workspace_id,
project_id=project.id,
is_uploaded=True,
)
.aggregate(total=Sum("size"))
.get("total")
)
return int(total or 0)
def get_project_storage_quota_state(project, requested_size=0):
quota = get_project_storage_quota(project)
used = get_project_storage_usage(project)
requested = max(int(requested_size or 0), 0)
if quota is None:
return {
"enabled": False,
"quota": 0,
"used": used,
"remaining": None,
"allowed": True,
"exceeded": False,
}
remaining = max(quota - used, 0)
return {
"enabled": True,
"quota": quota,
"used": used,
"remaining": remaining,
"allowed": used + requested <= quota,
"exceeded": used > quota,
}
def get_project_storage_quota_response(project, requested_size=0):
quota_state = get_project_storage_quota_state(project, requested_size=requested_size)
if quota_state["allowed"]:
return None
return Response(
{
"error": "Project storage quota exceeded.",
"status": False,
"code": "project_storage_quota_exceeded",
"project_id": str(project.id),
"quota": quota_state["quota"],
"used": quota_state["used"],
"remaining": quota_state["remaining"],
},
status=status.HTTP_400_BAD_REQUEST,
)

View File

@ -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,
},
});

View File

@ -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
)}.`;
};

View File

@ -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)

View File

@ -251,9 +251,47 @@ export interface IWorkspaceStorageProjectSummary {
dedup_savings: number;
failed_upload_count: number;
failed_upload_size: number;
failed_preview_count: number;
failed_preview_size: number;
stale_unuploaded_count: number;
soft_deleted_count: number;
soft_deleted_size: number;
uploaded_without_blob_count: number;
quota_enabled: boolean;
quota: number;
quota_used: number;
quota_remaining: number | null;
quota_percent: number;
quota_exceeded: boolean;
quota_warning: boolean;
}
export interface IWorkspaceStorageProjectQuotaResponse {
id: string;
quota_enabled: boolean;
quota: number;
quota_used: number;
quota_remaining: number | null;
quota_percent: number;
quota_exceeded: boolean;
quota_warning: boolean;
}
export type TWorkspaceStorageMaintenanceAction =
| "scan_missing_blobs"
| "purge_stale_unuploaded"
| "purge_expired_deleted"
| "purge_orphaned_blobs";
export interface IWorkspaceStorageMaintenanceResponse {
action: TWorkspaceStorageMaintenanceAction;
scanned_count?: number;
missing_count?: number;
restored_count?: number;
deleted_asset_count?: number;
deleted_blob_count?: number;
deleted_object_count?: number;
released_size?: number;
}
export interface IWorkspaceStorageSummaryResponse {
@ -283,6 +321,22 @@ export interface IWorkspaceStorageSummaryResponse {
orphaned_blob_size: number;
missing_blob_count: number;
missing_blob_size: number;
failed_preview_count: number;
failed_preview_size: number;
failed_preview_instrumented: boolean;
quota_warning_project_count: number;
quota_exceeded_project_count: number;
};
cleanup: {
workspace_slug: string;
unuploaded_retention_days: number;
soft_deleted_retention_days: number;
unuploaded_ready_count: number;
unuploaded_ready_size: number;
soft_deleted_ready_count: number;
soft_deleted_ready_size: number;
orphaned_blob_ready_count: number;
orphaned_blob_ready_size: number;
};
projects: IWorkspaceStorageProjectSummary[];
}