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

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, UserRecentVisitViewSet,
WorkspaceHomePreferenceViewSet, WorkspaceHomePreferenceViewSet,
WorkspaceStickyViewSet, WorkspaceStickyViewSet,
WorkspaceStorageMaintenanceEndpoint,
WorkspaceStorageProjectQuotaEndpoint,
WorkspaceStorageSummaryEndpoint, WorkspaceStorageSummaryEndpoint,
WorkspaceUserPreferenceViewSet, WorkspaceUserPreferenceViewSet,
) )
@ -263,6 +265,16 @@ urlpatterns = [
WorkspaceStorageSummaryEndpoint.as_view(), WorkspaceStorageSummaryEndpoint.as_view(),
name="workspace-storage-summary", 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 # User Preference
path( path(
"workspaces/<str:slug>/sidebar-preferences/", "workspaces/<str:slug>/sidebar-preferences/",

View File

@ -83,7 +83,11 @@ from .workspace.module import WorkspaceModulesEndpoint
from .workspace.cycle import WorkspaceCyclesEndpoint from .workspace.cycle import WorkspaceCyclesEndpoint
from .workspace.quick_link import QuickLinkViewSet from .workspace.quick_link import QuickLinkViewSet
from .workspace.sticky import WorkspaceStickyViewSet from .workspace.sticky import WorkspaceStickyViewSet
from .workspace.storage import WorkspaceStorageSummaryEndpoint from .workspace.storage import (
WorkspaceStorageMaintenanceEndpoint,
WorkspaceStorageProjectQuotaEndpoint,
WorkspaceStorageSummaryEndpoint,
)
from .state.base import StateViewSet, IntakeStateEndpoint from .state.base import StateViewSet, IntakeStateEndpoint
from .view.base import ( 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.app.permissions import allow_permission, ROLE
from plane.utils.cache import invalidate_cache_directly from plane.utils.cache import invalidate_cache_directly
from plane.throttles.asset import AssetRateThrottle 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 ( from plane.utils.file_dedup import (
UploadedObjectMissing, UploadedObjectMissing,
attach_existing_blob_to_file_asset, attach_existing_blob_to_file_asset,
@ -361,6 +361,14 @@ class WorkspaceFileAssetEndpoint(BaseAPIView):
# Get the size limit # Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size) 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
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -564,6 +572,10 @@ class ProjectAssetEndpoint(BaseAPIView):
# Get the size limit # Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size) 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
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@ -576,7 +588,7 @@ class ProjectAssetEndpoint(BaseAPIView):
workspace=workspace, workspace=workspace,
created_by=request.user, created_by=request.user,
entity_type=entity_type, entity_type=entity_type,
project_id=project_id, project_id=project.id,
**self.get_entity_id_field(entity_type, entity_identifier), **self.get_entity_id_field(entity_type, entity_identifier),
) )
@ -767,10 +779,6 @@ class DuplicateAssetEndpoint(BaseAPIView):
) )
workspace = Workspace.objects.get(slug=slug) 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) storage = S3Storage(request=request)
original_asset = FileAsset.objects.filter(id=asset_id, workspace=workspace, is_uploaded=True).first() 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: if not original_asset:
return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND) 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')}" destination_key = f"{workspace.id}/{uuid.uuid4().hex}-{original_asset.attributes.get('name')}"
duplicated_asset = FileAsset.objects.create( duplicated_asset = FileAsset.objects.create(
attributes={ attributes={

View File

@ -19,12 +19,12 @@ from rest_framework.parsers import MultiPartParser, FormParser
# Module imports # Module imports
from .. import BaseAPIView from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer 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.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.host import base_host 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.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 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 # Get the workspace
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
# asset key # asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}" asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
# Get the size limit # Get the size limit
size_limit = resolve_workspace_upload_size_limit(workspace, size) 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 # Create a File Asset
asset = FileAsset.objects.create( asset = FileAsset.objects.create(

View File

@ -2,8 +2,10 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details. # See the LICENSE file for details.
import os
from datetime import timedelta from datetime import timedelta
from django.conf import settings
from django.db.models import Sum from django.db.models import Sum
from django.utils import timezone from django.utils import timezone
from rest_framework import status 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.permissions import ROLE, allow_permission
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Project, StoredBlob, Workspace 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): 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) 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): class WorkspaceStorageSummaryEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE") @allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
def get(self, request, slug): def get(self, request, slug):
workspace = Workspace.objects.get(slug=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) 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()) 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_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( failed_uploads = FileAsset.all_objects.filter(
workspace=workspace, workspace=workspace,
is_uploaded=False, is_uploaded=False,
deleted_at__isnull=True, 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) soft_deleted_assets = FileAsset.all_objects.filter(workspace=workspace, deleted_at__isnull=False)
orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED) orphaned_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.ORPHANED)
missing_blobs = StoredBlob.objects.filter(workspace=workspace, status=StoredBlob.Status.MISSING) 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_assets.exclude(blob__isnull=True).values_list("blob_id", flat=True).distinct()
) )
project_logical_size = _sum_asset_size(project_assets) 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_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_soft_deleted = soft_deleted_assets.filter(project=project)
project_uploaded_without_blob = uploaded_without_blob.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), "dedup_savings": _dedup_savings(project_logical_size, project_physical_size),
"failed_upload_count": project_failed_uploads.count(), "failed_upload_count": project_failed_uploads.count(),
"failed_upload_size": _sum_asset_size(project_failed_uploads), "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_count": project_soft_deleted.count(),
"soft_deleted_size": _sum_asset_size(project_soft_deleted), "soft_deleted_size": _sum_asset_size(project_soft_deleted),
"uploaded_without_blob_count": project_uploaded_without_blob.count(), "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))), "orphaned_blob_size": _sum_blob_size(list(orphaned_blobs.values_list("id", flat=True))),
"missing_blob_count": missing_blobs.count(), "missing_blob_count": missing_blobs.count(),
"missing_blob_size": _sum_blob_size(list(missing_blobs.values_list("id", flat=True))), "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, "projects": project_rows,
} }
return Response(data, status=status.HTTP_200_OK) 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 from celery import shared_task
# Module imports # Module imports
from plane.db.models import FileAsset, StoredBlob from plane.db.models import FileAsset
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.file_dedup import release_file_asset_blob from plane.utils.file_dedup import hard_delete_file_asset, 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)
@shared_task @shared_task
@ -74,4 +41,4 @@ def delete_expired_file_asset():
expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff) expired_assets = FileAsset.all_objects.filter(deleted_at__lt=cutoff)
for asset in expired_assets.iterator(): 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_time_tracking_enabled = models.BooleanField(default=False)
is_issue_type_enabled = models.BooleanField(default=False) is_issue_type_enabled = models.BooleanField(default=False)
guest_view_all_features = 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 = models.TextField(blank=True, null=True)
cover_image_asset = models.ForeignKey( cover_image_asset = models.ForeignKey(
"db.FileAsset", "db.FileAsset",

View File

@ -6,10 +6,28 @@ from urllib.parse import quote
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from django.http import HttpResponse, StreamingHttpResponse from django.http import HttpResponse, StreamingHttpResponse
from django.utils import timezone
from plane.settings.storage import S3Storage 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): def attachment_object_exists(asset):
storage = S3Storage(request=None) storage = S3Storage(request=None)
try: try:
@ -34,7 +52,11 @@ def get_attachment_preview_response(request, asset, disposition="inline"):
try: try:
storage_response = storage.s3_client.get_object(**request_kwargs) 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) return HttpResponse("Attachment object not found.", status=404)
content_type = ( 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) 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: def attach_existing_blob_to_file_asset(source: FileAsset, target: FileAsset) -> bool:
if not source.blob_id: if not source.blob_id:
source.refresh_from_db(fields=["blob"]) source.refresh_from_db(fields=["blob"])

View File

@ -1,4 +1,7 @@
from django.conf import settings 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): def get_workspace_file_size_limit(workspace):
@ -22,3 +25,71 @@ def resolve_workspace_upload_size_limit(workspace, requested_size):
return requested_size return requested_size
return min(requested_size, workspace_limit) 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; 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 = ( export const useAttachmentOperations = (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
@ -43,14 +72,14 @@ export const useAttachmentOperations = (
if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields");
const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file); const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, file);
setPromiseToast(attachmentUploadPromise, { setPromiseToast(attachmentUploadPromise, {
loading: "Uploading attachment...", loading: "Загружаем вложение...",
success: { success: {
title: "Attachment uploaded", title: "Вложение загружено",
message: () => "The attachment has been successfully uploaded", message: () => "Файл успешно добавлен к рабочему элементу.",
}, },
error: { error: {
title: "Attachment not uploaded", title: "Вложение не загружено",
message: () => "The attachment could not be uploaded", message: getAttachmentUploadErrorMessage,
}, },
}); });

View File

@ -4,11 +4,26 @@
* See the LICENSE file for details. * 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 type { ElementType } from "react";
import { useEffect, useState } from "react";
import useSWR from "swr"; import useSWR from "swr";
// plane imports // 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"; import { cn } from "@plane/utils";
// components // components
import { SettingsHeading } from "@/components/settings/heading"; import { SettingsHeading } from "@/components/settings/heading";
@ -16,6 +31,7 @@ import { SettingsHeading } from "@/components/settings/heading";
import { WorkspaceService } from "@/services/workspace.service"; import { WorkspaceService } from "@/services/workspace.service";
const workspaceService = new WorkspaceService(); const workspaceService = new WorkspaceService();
const MEGABYTE = 1024 * 1024;
const formatBytes = (value: number) => { const formatBytes = (value: number) => {
const bytes = Number(value || 0); 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 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: { const StatCard = (props: {
title: string; title: string;
value: string; value: string;
@ -59,12 +79,17 @@ const StatCard = (props: {
); );
}; };
const ProjectStorageRow = (props: { project: IWorkspaceStorageProjectSummary; maxSize: number }) => { const ProjectStorageRow = (props: {
const { project, maxSize } = 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; const ratio = maxSize > 0 ? Math.max((project.logical_size / maxSize) * 100, project.logical_size > 0 ? 3 : 0) : 0;
return ( 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"> <div className="flex min-w-0 flex-col">
<span className="truncate text-14 font-semibold text-primary">{project.name}</span> <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> <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> </div>
<StorageValue>{formatBytes(project.physical_size)}</StorageValue> <StorageValue>{formatBytes(project.physical_size)}</StorageValue>
<StorageValue accent>{formatBytes(project.dedup_savings)}</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 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> </div>
); );
}; };
@ -98,24 +125,177 @@ const StorageValue = (props: { accent?: boolean; children: string; warning?: boo
); );
const ProjectStorageHeader = () => ( 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>Файлы</span> <span>Файлы</span>
<span>Blob</span> <span>Blob</span>
<span>Логический объем</span> <span>Логический объем</span>
<span>Физический</span> <span>Физический</span>
<span>Дедуп</span> <span>Дедуп</span>
<span>Квота</span>
<span>Ошибки</span> <span>Ошибки</span>
<span>Зависшие</span>
<span>Удалено</span> <span>Удалено</span>
</div> </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 = { type TStorageSettingsContentProps = {
workspaceSlug: string; workspaceSlug: string;
}; };
export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsContentProps) { 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, workspaceSlug ? ["workspace-storage-summary", workspaceSlug] : null,
([, slug]) => workspaceService.fetchWorkspaceStorageSummary(slug) ([, 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 projects = [...(data?.projects ?? [])].sort((a, b) => b.logical_size - a.logical_size);
const maxProjectSize = Math.max(...projects.map((project) => project.logical_size), 0); 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 ( return (
<div className="flex w-full flex-col gap-7"> <div className="flex w-full flex-col gap-7">
<SettingsHeading <SettingsHeading
@ -176,7 +377,7 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
/> />
</div> </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 <StatCard
title="Зависшие загрузки" title="Зависшие загрузки"
value={formatCount(data.diagnostics.stale_unuploaded_count)} 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)} вне активных ссылок`} caption={`${formatBytes(data.diagnostics.orphaned_blob_size + data.diagnostics.missing_blob_size)} вне активных ссылок`}
icon={Database} 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> </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"> <section className="nodedc-settings-card p-5">
<div className="mb-5 flex items-center justify-between gap-4"> <div className="mb-5 flex items-center justify-between gap-4">
<div> <div>
@ -209,11 +479,17 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
</div> </div>
<div className="overflow-x-auto"> <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 /> <ProjectStorageHeader />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{projects.map((project) => ( {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>
</div> </div>
@ -224,3 +500,29 @@ export function StorageSettingsContent({ workspaceSlug }: TStorageSettingsConten
</div> </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, IWorkspaceSidebarNavigationItem,
IWorkspaceSidebarNavigation, IWorkspaceSidebarNavigation,
IWorkspaceUserPropertiesResponse, IWorkspaceUserPropertiesResponse,
IWorkspaceStorageMaintenanceResponse,
IWorkspaceStorageProjectQuotaResponse,
IWorkspaceStorageSummaryResponse, IWorkspaceStorageSummaryResponse,
TWorkspaceStorageMaintenanceAction,
} from "@plane/types"; } from "@plane/types";
// services // services
import { APIService } from "@/services/api.service"; 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> { async createWorkspace(data: Partial<IWorkspace>): Promise<IWorkspace> {
return this.post("/api/workspaces/", data) return this.post("/api/workspaces/", data)
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -251,9 +251,47 @@ export interface IWorkspaceStorageProjectSummary {
dedup_savings: number; dedup_savings: number;
failed_upload_count: number; failed_upload_count: number;
failed_upload_size: number; failed_upload_size: number;
failed_preview_count: number;
failed_preview_size: number;
stale_unuploaded_count: number;
soft_deleted_count: number; soft_deleted_count: number;
soft_deleted_size: number; soft_deleted_size: number;
uploaded_without_blob_count: 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 { export interface IWorkspaceStorageSummaryResponse {
@ -283,6 +321,22 @@ export interface IWorkspaceStorageSummaryResponse {
orphaned_blob_size: number; orphaned_blob_size: number;
missing_blob_count: number; missing_blob_count: number;
missing_blob_size: 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[]; projects: IWorkspaceStorageProjectSummary[];
} }