7 Commits
29 changed files with 1936 additions and 610 deletions
@@ -1,4 +1,11 @@
services:
api:
image: nodedc/plane-backend:bim-gateway-test
environment:
PLANE_NODEDC_BIM_ACCESS_TOKEN: local-tasker-bim-gateway-test
PLANE_NODEDC_BIM_EMBED_SECRET: local-tasker-bim-gateway-test
PLANE_NODEDC_BIM_INTERNAL_URL: http://host.docker.internal:8080
PLANE_NODEDC_BIM_PUBLIC_URL: http://localhost:8080
web:
volumes:
- ./.local-web-root:/usr/share/nginx/html:ro
@@ -26,6 +26,11 @@ from plane.app.views import (
IssuePaginatedViewSet,
IssueDetailEndpoint,
IssueAttachmentV2Endpoint,
IssueBimAttachmentStatusEndpoint,
IssueBimAttachmentUploadEndpoint,
IssueBimAttachmentVersionDetailEndpoint,
IssueBimAttachmentVersionsEndpoint,
IssueBimAttachmentViewerEndpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
WorkItemDescriptionVersionEndpoint,
@@ -144,6 +149,31 @@ urlpatterns = [
IssueAttachmentV2Endpoint.as_view(),
name="project-issue-attachments",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/bim-upload/",
IssueBimAttachmentUploadEndpoint.as_view(),
name="project-issue-bim-attachment-upload",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-status/",
IssueBimAttachmentStatusEndpoint.as_view(),
name="project-issue-bim-attachment-status",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/",
IssueBimAttachmentVersionsEndpoint.as_view(),
name="project-issue-bim-attachment-versions",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-versions/<str:version_id>/",
IssueBimAttachmentVersionDetailEndpoint.as_view(),
name="project-issue-bim-attachment-version-detail",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/attachments/<uuid:pk>/bim-viewer/",
IssueBimAttachmentViewerEndpoint.as_view(),
name="project-issue-bim-attachment-viewer",
),
## End Issues
## Issue Activity
path(
@@ -145,6 +145,14 @@ from .issue.attachment import (
IssueAttachmentV2Endpoint,
)
from .issue.bim_attachment import (
IssueBimAttachmentStatusEndpoint,
IssueBimAttachmentUploadEndpoint,
IssueBimAttachmentVersionDetailEndpoint,
IssueBimAttachmentVersionsEndpoint,
IssueBimAttachmentViewerEndpoint,
)
from .issue.comment import IssueCommentViewSet, CommentReactionViewSet
from .issue.label import LabelViewSet, BulkCreateIssueLabelsEndpoint
@@ -27,6 +27,7 @@ from plane.utils.host import base_host
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
from plane.utils.nodedc_bim_gateway import BimGatewayError, bim_gateway_request, get_bim_registry_identity
class IssueAttachmentEndpoint(BaseAPIView):
@@ -109,12 +110,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
name = request.data.get("name")
type = request.data.get("type", False)
size = int(request.data.get("size", settings.FILE_SIZE_LIMIT))
beam_viewer = request.data.get("beamViewer")
is_beam_viewer_reference = (
isinstance(beam_viewer, dict)
and beam_viewer.get("src")
and beam_viewer.get("downloadUrl")
)
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
@@ -126,45 +121,6 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
project = Project.objects.get(id=project_id, workspace=workspace)
if is_beam_viewer_reference:
asset = FileAsset.objects.create(
attributes={
"name": name,
"type": type,
"size": size,
"beamViewer": beam_viewer,
},
asset=f"{workspace.id}/beam-viewer/{uuid.uuid4().hex}-{name}",
size=0,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
serializer = IssueAttachmentSerializer(asset)
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
return Response(
{
"asset_id": str(asset.id),
"attachment": serializer.data,
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
@@ -205,7 +161,32 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
issue_attachment = FileAsset.objects.select_related("created_by").get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
)
attributes = issue_attachment.attributes if isinstance(issue_attachment.attributes, dict) else {}
beam_viewer = attributes.get("beamViewer")
if issue_attachment.external_source == "beam-viewer" and isinstance(beam_viewer, dict):
try:
bim_gateway_request(
"DELETE",
"/api/uploads/asset",
identity=get_bim_registry_identity(issue_attachment, beam_viewer),
json_payload=beam_viewer,
)
except BimGatewayError as error:
return Response(
{
"ok": False,
"error": error.code,
"message": error.message,
**({"upstream_status": error.upstream_status} if error.upstream_status else {}),
},
status=error.status_code,
)
if not issue_attachment.is_uploaded:
release_file_asset_blob(issue_attachment, request=request, delete_untracked_object=True)
issue_attachment.is_deleted = True
@@ -256,28 +237,22 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
issue_attachment = FileAsset.objects.get(
pk=pk,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
)
beam_viewer = request.data.get("beamViewer")
if isinstance(beam_viewer, dict):
attributes = issue_attachment.attributes or {}
existing_beam_viewer = attributes.get("beamViewer")
if not isinstance(existing_beam_viewer, dict):
return Response(
{"error": "The attachment is not a BIM Viewer reference.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
attributes["beamViewer"] = {
**existing_beam_viewer,
**beam_viewer,
}
for attribute_key in ("name", "size", "type", "version"):
if attribute_key in request.data:
attributes[attribute_key] = request.data.get(attribute_key)
issue_attachment.attributes = attributes
issue_attachment.is_uploaded = True
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
return Response(IssueAttachmentSerializer(issue_attachment).data, status=status.HTTP_200_OK)
return Response(
{
"error": "BIM attachments are managed by the Ops BIM gateway.",
"status": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = IssueAttachmentSerializer(issue_attachment)
if not attachment_object_exists(issue_attachment):
@@ -0,0 +1,465 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
import logging
import re
import uuid
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from plane.app.permissions import ROLE, allow_permission
from plane.app.serializers import IssueAttachmentSerializer
from plane.app.views import BaseAPIView
from plane.bgtasks.issue_activities_task import issue_activity
from plane.db.models import FileAsset, Project, Workspace
from plane.utils.host import base_host
from plane.utils.nodedc_bim_gateway import (
BimGatewayError,
bim_gateway_request,
build_bim_attachment,
build_bim_embed_url,
find_bim_version,
get_bim_model_mime_type,
get_bim_model_type,
get_bim_registry_identity,
make_bim_registry_owner_id,
merge_bim_versions,
resolve_bim_viewer_model,
to_bim_public_url,
to_bim_relative_asset_url,
)
from plane.utils.upload_limits import resolve_workspace_upload_size_limit
logger = logging.getLogger(__name__)
def _error_response(error):
return Response(
{
"ok": False,
"error": error.code,
"message": error.message,
**({"upstream_status": error.upstream_status} if error.upstream_status else {}),
},
status=error.status_code,
)
def _get_attachment(slug, project_id, issue_id, attachment_id):
return (
FileAsset.objects.select_related("created_by")
.filter(
id=attachment_id,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
.first()
)
def _get_beam_viewer(attachment):
attributes = attachment.attributes if isinstance(attachment.attributes, dict) else {}
beam_viewer = attributes.get("beamViewer")
return beam_viewer if isinstance(beam_viewer, dict) else None
def _upload_group_id(slug, project_id, issue_id):
value = "{}_{}_{}".format(slug, project_id, issue_id)
return re.sub(r"[^a-zA-Z0-9_-]", "_", value) or "tasker"
def _validate_upload(workspace, uploaded_file):
if uploaded_file is None:
return Response(
{"ok": False, "error": "bim_file_required", "message": "A BIM model file is required."},
status=status.HTTP_400_BAD_REQUEST,
)
if not get_bim_model_type(uploaded_file.name):
return Response(
{
"ok": False,
"error": "bim_format_not_supported",
"message": "This BIM model format is not supported.",
},
status=status.HTTP_400_BAD_REQUEST,
)
size_limit = resolve_workspace_upload_size_limit(workspace, uploaded_file.size)
if uploaded_file.size > size_limit:
return Response(
{
"ok": False,
"error": "bim_file_too_large",
"message": "The BIM model exceeds the workspace upload limit.",
"limit": size_limit,
},
status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
)
return None
def _activity_created(request, serializer, issue_id, project_id):
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
actor_id=str(request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=json.dumps(serializer.data, cls=DjangoJSONEncoder),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=base_host(request=request, is_app=True),
)
def _delete_uploaded_asset(beam_viewer, identity):
try:
bim_gateway_request(
"DELETE",
"/api/uploads/asset",
identity=identity,
json_payload=beam_viewer,
)
except BimGatewayError:
logger.exception("Failed to clean up BIM asset after an OPS attachment failure")
def _delete_uploaded_version(version, identity):
try:
bim_gateway_request(
"DELETE",
"/api/uploads/version",
identity=identity,
json_payload=version,
)
except BimGatewayError:
logger.exception("Failed to clean up BIM version after an OPS attachment failure")
def _public_status_payload(payload):
result = dict(payload)
result["artifactUrl"] = to_bim_public_url(payload.get("artifactSrc"))
result["metadataUrl"] = to_bim_public_url(payload.get("metadataSrc"))
return result
def _public_version(version):
result = dict(version)
for key in ("src", "sourceSrc", "downloadUrl"):
if result.get(key):
result[key] = to_bim_public_url(result[key])
result["viewerUrl"] = None
return result
class IssueBimAttachmentUploadEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id):
workspace = Workspace.objects.get(slug=slug)
Project.objects.get(id=project_id, workspace=workspace)
uploaded_file = request.FILES.get("file")
validation_error = _validate_upload(workspace, uploaded_file)
if validation_error is not None:
return validation_error
attachment_id = uuid.uuid4()
registry_owner_id = make_bim_registry_owner_id(attachment_id)
identity = {"id": registry_owner_id, "email": request.user.email or ""}
uploaded_file.seek(0)
try:
upload_payload = bim_gateway_request(
"POST",
"/api/uploads",
identity=identity,
params={
"filename": uploaded_file.name,
"projectId": _upload_group_id(slug, project_id, issue_id),
"assetId": str(attachment_id),
"version": "1",
},
data=uploaded_file.file,
content_length=uploaded_file.size,
)
beam_viewer = build_bim_attachment(
upload_payload,
filename=uploaded_file.name,
size=uploaded_file.size,
uploaded_by=request.user.id,
registry_owner_id=registry_owner_id,
)
except BimGatewayError as error:
return _error_response(error)
try:
asset = FileAsset.objects.create(
id=attachment_id,
attributes={
"name": uploaded_file.name,
"type": get_bim_model_mime_type(uploaded_file.name),
"size": uploaded_file.size,
"version": beam_viewer.get("version") or 1,
"beamViewer": beam_viewer,
},
asset="{}/beam-viewer/{}-{}".format(workspace.id, attachment_id.hex, uploaded_file.name),
size=0,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
external_source="beam-viewer",
)
serializer = IssueAttachmentSerializer(asset)
_activity_created(request, serializer, issue_id, project_id)
except Exception:
_delete_uploaded_asset(beam_viewer, identity)
raise
return Response(
{
"asset_id": str(asset.id),
"attachment": serializer.data,
"asset_url": asset.asset_url,
},
status=status.HTTP_201_CREATED,
)
class IssueBimAttachmentStatusEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
source = to_bim_relative_asset_url(beam_viewer.get("src"))
if not source:
return Response({"error": "BIM attachment source is invalid."}, status=status.HTTP_422_UNPROCESSABLE_ENTITY)
try:
payload = bim_gateway_request(
"GET",
"/api/conversions/status",
identity=get_bim_registry_identity(attachment, beam_viewer),
params={"src": source},
)
except BimGatewayError as error:
return _error_response(error)
return Response(_public_status_payload(payload), status=status.HTTP_200_OK)
class IssueBimAttachmentVersionsEndpoint(BaseAPIView):
parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
params = {
"projectId": beam_viewer.get("projectId"),
"assetId": beam_viewer.get("assetId"),
}
if not all(params.values()):
params = {"src": to_bim_relative_asset_url(beam_viewer.get("src"))}
try:
payload = bim_gateway_request(
"GET",
"/api/uploads/versions",
identity=get_bim_registry_identity(attachment, beam_viewer),
params=params,
)
except BimGatewayError as error:
return _error_response(error)
result = dict(payload)
result["versions"] = [_public_version(version) for version in payload.get("versions") or []]
return Response(result, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
workspace = Workspace.objects.get(slug=slug)
uploaded_file = request.FILES.get("file")
validation_error = _validate_upload(workspace, uploaded_file)
if validation_error is not None:
return validation_error
versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else []
current_versions = [int(beam_viewer.get("version") or 1)]
current_versions.extend(int(version.get("version") or 0) for version in versions)
next_version = max(current_versions) + 1
identity = get_bim_registry_identity(attachment, beam_viewer)
uploaded_file.seek(0)
try:
upload_payload = bim_gateway_request(
"POST",
"/api/uploads",
identity=identity,
params={
"filename": uploaded_file.name,
"projectId": beam_viewer.get("projectId") or _upload_group_id(slug, project_id, issue_id),
"assetId": beam_viewer.get("assetId") or str(attachment.id),
"version": str(next_version),
},
data=uploaded_file.file,
content_length=uploaded_file.size,
)
next_beam_viewer = build_bim_attachment(
upload_payload,
filename=uploaded_file.name,
size=uploaded_file.size,
uploaded_by=request.user.id,
registry_owner_id=identity["id"],
)
next_beam_viewer["versions"] = merge_bim_versions(beam_viewer, next_beam_viewer)
except BimGatewayError as error:
return _error_response(error)
next_record = find_bim_version(next_beam_viewer, next_beam_viewer.get("versionId"))
try:
attributes = dict(attachment.attributes or {})
attributes.update(
{
"name": uploaded_file.name,
"type": get_bim_model_mime_type(uploaded_file.name),
"size": uploaded_file.size,
"version": next_beam_viewer.get("version") or next_version,
"beamViewer": next_beam_viewer,
}
)
attachment.attributes = attributes
attachment.save(update_fields=["attributes", "updated_at"])
except Exception:
if next_record:
_delete_uploaded_version(next_record, identity)
raise
return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK)
class IssueBimAttachmentVersionDetailEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def delete(self, request, slug, project_id, issue_id, pk, version_id):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
version = find_bim_version(beam_viewer, version_id)
if version is None:
return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND)
current_version_id = beam_viewer.get("versionId")
if (current_version_id and current_version_id == version.get("versionId")) or (
not current_version_id and beam_viewer.get("version") == version.get("version")
):
return Response(
{"error": "The current BIM version cannot be deleted."},
status=status.HTTP_409_CONFLICT,
)
try:
bim_gateway_request(
"DELETE",
"/api/uploads/version",
identity=get_bim_registry_identity(attachment, beam_viewer),
json_payload=version,
)
except BimGatewayError as error:
return _error_response(error)
versions = beam_viewer.get("versions") if isinstance(beam_viewer.get("versions"), list) else []
beam_viewer = {
**beam_viewer,
"versions": [
candidate
for candidate in versions
if not (
(version.get("versionId") and candidate.get("versionId") == version.get("versionId"))
or (not version.get("versionId") and candidate.get("version") == version.get("version"))
)
],
}
attributes = dict(attachment.attributes or {})
attributes["beamViewer"] = beam_viewer
attachment.attributes = attributes
attachment.save(update_fields=["attributes", "updated_at"])
return Response(IssueAttachmentSerializer(attachment).data, status=status.HTTP_200_OK)
class IssueBimAttachmentViewerEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def post(self, request, slug, project_id, issue_id, pk):
attachment = _get_attachment(slug, project_id, issue_id, pk)
if attachment is None:
return Response({"error": "BIM attachment not found."}, status=status.HTTP_404_NOT_FOUND)
beam_viewer = _get_beam_viewer(attachment)
if beam_viewer is None:
return Response({"error": "BIM attachment metadata is missing."}, status=status.HTTP_409_CONFLICT)
version_id = request.data.get("versionId")
version = find_bim_version(beam_viewer, version_id)
if version is None:
return Response({"error": "BIM version not found."}, status=status.HTTP_404_NOT_FOUND)
live_status = None
conversion = version.get("conversion") if isinstance(version.get("conversion"), dict) else {}
if conversion and conversion.get("status") != "ready":
source = to_bim_relative_asset_url(version.get("sourceSrc") or version.get("src"))
if source:
try:
live_status = _public_status_payload(
bim_gateway_request(
"GET",
"/api/conversions/status",
identity=get_bim_registry_identity(attachment, beam_viewer),
params={"src": source},
)
)
except BimGatewayError as error:
return _error_response(error)
try:
model = resolve_bim_viewer_model(
beam_viewer,
version_id=version_id,
live_status=live_status,
)
viewer_url, expires_at = build_bim_embed_url(
model,
request.user,
slug,
project_id,
issue_id,
attachment.id,
)
except BimGatewayError as error:
return _error_response(error)
return Response(
{
"ok": True,
"viewerUrl": viewer_url,
"expiresAt": expires_at,
},
status=status.HTTP_200_OK,
)
@@ -128,6 +128,8 @@ def serialize_comment(comment):
"body": comment.comment_html,
"actor_id": str(comment.actor_id) if comment.actor_id else None,
"created_at": comment.created_at.isoformat(),
"updated_at": comment.updated_at.isoformat(),
"edited_at": comment.edited_at.isoformat() if comment.edited_at else None,
}
@@ -986,6 +988,53 @@ class NodeDCAgentIssueCommentEndpoint(View):
comment.save(created_by_id=actor.id)
return JsonResponse({"ok": True, "comment": serialize_comment(comment)}, status=201)
def patch(self, request, issue_id, comment_id):
error_response = validate_internal_request(request)
if error_response is not None:
return error_response
payload = parse_json_body(request)
if payload is None:
return invalid_json_response()
project, issue = resolve_issue(payload.get("project_id"), issue_id, payload.get("workspace_slug"))
if project is None:
return validation_error("project_not_found", status=404)
if issue is None:
return validation_error("issue_not_found", status=404)
project_access_error = validate_agent_project_access(request, project)
if project_access_error is not None:
return project_access_error
body = payload.get("body")
if not isinstance(body, str) or not body.strip():
return validation_error("body_required")
actor = ensure_agent_actor(request, project.workspace, project, payload)
if actor is None:
return validation_error("missing_agent_headers")
comment = IssueComment.objects.filter(
id=comment_id,
issue=issue,
project=project,
actor=actor,
deleted_at__isnull=True,
).first()
if comment is None:
return validation_error("comment_not_found_or_not_owned", status=404)
comment.comment_html = html_from_text(body)
comment.comment_json = {}
comment.edited_at = timezone.now()
comment.updated_by = actor
comment.save(
update_fields=["comment_html", "comment_json", "edited_at", "updated_by", "updated_at"],
disable_auto_set_user=True,
)
return JsonResponse({"ok": True, "comment": serialize_comment(comment)})
@method_decorator(csrf_exempt, name="dispatch")
class NodeDCAgentIssueLabelsEndpoint(View):
@@ -461,8 +461,14 @@ ATTACHMENT_MIME_TYPES = [
"text/css",
"text/javascript",
"application/json",
"application/x-ndjson",
"application/yaml",
"application/x-yaml",
"text/yaml",
"application/toml",
"text/xml",
"text/csv",
"text/tab-separated-values",
"application/xml",
# SQL
"application/x-sql",
@@ -470,6 +476,7 @@ ATTACHMENT_MIME_TYPES = [
"application/x-gzip",
# Markdown
"text/markdown",
"text/x-markdown",
]
# Seed directory path
@@ -0,0 +1,102 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import base64
import json
from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit
import pytest
from plane.utils.nodedc_bim_gateway import (
BimGatewayError,
build_bim_embed_url,
get_bim_direct_model_type,
get_bim_model_type,
resolve_bim_viewer_model,
to_bim_relative_asset_url,
)
@pytest.mark.unit
def test_cad_extensions_are_normalized():
assert get_bim_model_type("part.STEP") == "step"
assert get_bim_model_type("part.stp") == "step"
assert get_bim_model_type("part.IGES") == "iges"
assert get_bim_model_type("part.igs") == "iges"
assert get_bim_direct_model_type("part.glb") == "gltf"
assert get_bim_direct_model_type("part.step") is None
@pytest.mark.unit
def test_only_managed_public_bim_sources_are_accepted(monkeypatch):
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
assert (
to_bim_relative_asset_url("https://bim.example/uploads/project/model.glb?version=2")
== "/uploads/project/model.glb?version=2"
)
assert to_bim_relative_asset_url("/data/project/model.xkt") == "/data/project/model.xkt"
assert to_bim_relative_asset_url("https://attacker.example/uploads/model.glb") is None
assert to_bim_relative_asset_url("/api/auth/session") is None
@pytest.mark.unit
def test_embed_grant_is_short_lived_and_bound_to_ops_context(monkeypatch):
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_SECRET", "test-embed-secret")
monkeypatch.setenv("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180")
model = {
"url": "/uploads/project/model.glb",
"settingsSrc": "/uploads/project/model.glb",
"type": "gltf",
"name": "model.glb",
}
viewer_url, expires_at = build_bim_embed_url(
model,
SimpleNamespace(id="user-1"),
"workspace",
"project-1",
"issue-1",
"attachment-1",
)
query = parse_qs(urlsplit(viewer_url).query)
encoded_payload = query["token"][0].split(".", 1)[0]
encoded_payload += "=" * (-len(encoded_payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(encoded_payload).decode("utf-8"))
assert urlsplit(viewer_url).path == "/embed/tasker"
assert claims["sub"] == "user-1"
assert claims["workspace"] == "workspace"
assert claims["project"] == "project-1"
assert claims["issue"] == "issue-1"
assert claims["attachment"] == "attachment-1"
assert claims["model"] == model
assert expires_at - claims["iat"] == 180
@pytest.mark.unit
def test_viewer_model_rejects_unmanaged_or_unready_sources(monkeypatch):
monkeypatch.setenv("PLANE_NODEDC_BIM_PUBLIC_URL", "https://bim.example")
with pytest.raises(BimGatewayError, match="still being prepared"):
resolve_bim_viewer_model(
{
"originalFilename": "part.step",
"previewAvailable": False,
"src": "https://bim.example/uploads/project/part.step",
"type": "step",
}
)
with pytest.raises(BimGatewayError, match="outside the managed BIM storage"):
resolve_bim_viewer_model(
{
"originalFilename": "model.glb",
"previewAvailable": True,
"src": "https://attacker.example/uploads/model.glb",
"type": "gltf",
}
)
+5
View File
@@ -120,6 +120,11 @@ urlpatterns = [
NodeDCAgentIssueCommentEndpoint.as_view(),
name="nodedc-agent-issue-comment",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/comments/<uuid:comment_id>",
NodeDCAgentIssueCommentEndpoint.as_view(),
name="nodedc-agent-issue-comment-detail",
),
path(
"api/internal/nodedc/agent/issues/<uuid:issue_id>/labels",
NodeDCAgentIssueLabelsEndpoint.as_view(),
@@ -0,0 +1,420 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import base64
import hashlib
import hmac
import json
import os
import time
from urllib.parse import urlencode, urljoin, urlsplit
import requests
BIM_EMBED_AUDIENCE = "nodedc-bim-tasker-embed"
BIM_EMBED_ISSUER = "nodedc-tasker"
BIM_SUPPORTED_MODEL_TYPES = {
"bim": "bim",
"glb": "gltf",
"gltf": "gltf",
"iges": "iges",
"igs": "iges",
"las": "las",
"laz": "las",
"obj": "obj",
"step": "step",
"stl": "stl",
"stp": "step",
"xkt": "xkt",
}
BIM_DIRECT_MODEL_TYPES = {
"bim": "bim",
"glb": "gltf",
"gltf": "gltf",
"las": "las",
"laz": "las",
"obj": "obj",
"stl": "stl",
"xkt": "xkt",
}
class BimGatewayError(Exception):
def __init__(self, code, message, status_code=502, upstream_status=None):
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.upstream_status = upstream_status
def get_bim_gateway_config():
public_base_url = (
os.environ.get("PLANE_NODEDC_BIM_PUBLIC_URL", "").strip()
or os.environ.get("NODEDC_BIM_PUBLIC_URL", "").strip()
or "https://bim.nodedc.tech"
).rstrip("/")
internal_base_url = (
os.environ.get("PLANE_NODEDC_BIM_INTERNAL_URL", "").strip()
or os.environ.get("PLANE_NODEDC_BIM_URL", "").strip()
or public_base_url
).rstrip("/")
token = (
os.environ.get("PLANE_NODEDC_BIM_ACCESS_TOKEN", "").strip()
or os.environ.get("PLANE_NODEDC_ACCESS_TOKEN", "").strip()
or os.environ.get("NODEDC_INTERNAL_ACCESS_TOKEN", "").strip()
)
embed_secret = (
os.environ.get("PLANE_NODEDC_BIM_EMBED_SECRET", "").strip()
or os.environ.get("NODEDC_BIM_EMBED_SECRET", "").strip()
or token
)
connect_timeout = float(os.environ.get("PLANE_NODEDC_BIM_CONNECT_TIMEOUT_SECONDS", "5") or "5")
read_timeout = float(os.environ.get("PLANE_NODEDC_BIM_READ_TIMEOUT_SECONDS", "180") or "180")
embed_ttl = max(30, int(os.environ.get("PLANE_NODEDC_BIM_EMBED_TTL_SECONDS", "180") or "180"))
return {
"public_base_url": public_base_url,
"internal_base_url": internal_base_url,
"token": token,
"embed_secret": embed_secret,
"timeout": (connect_timeout, read_timeout),
"embed_ttl": embed_ttl,
}
def get_bim_model_type(filename):
extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".")
return BIM_SUPPORTED_MODEL_TYPES.get(extension)
def get_bim_direct_model_type(filename):
extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".")
return BIM_DIRECT_MODEL_TYPES.get(extension)
def get_bim_model_mime_type(filename):
extension = os.path.splitext(str(filename or ""))[1].lower().lstrip(".")
if extension == "glb":
return "model/gltf-binary"
if extension == "gltf":
return "model/gltf+json"
return "application/octet-stream"
def make_bim_registry_owner_id(attachment_id):
return "tasker-attachment:{}".format(attachment_id)
def get_bim_registry_identity(attachment, beam_viewer=None):
beam_viewer = beam_viewer if isinstance(beam_viewer, dict) else {}
registry_owner_id = beam_viewer.get("registryOwnerId")
if isinstance(registry_owner_id, str) and registry_owner_id.strip():
return {
"id": registry_owner_id.strip(),
"email": "",
}
created_by = getattr(attachment, "created_by", None)
created_by_id = getattr(attachment, "created_by_id", None)
return {
"id": str(created_by_id or getattr(attachment, "id", "")),
"email": getattr(created_by, "email", "") or "",
}
def _gateway_headers(identity, content_type=None, content_length=None):
config = get_bim_gateway_config()
if not config["token"]:
raise BimGatewayError(
"bim_gateway_not_configured",
"NODE.DC BIM gateway token is not configured.",
status_code=503,
)
identity = identity or {}
headers = {
"Authorization": "Bearer {}".format(config["token"]),
"Accept": "application/json",
"X-NODEDC-Service": "tasker",
"X-NODEDC-User-Id": str(identity.get("id") or "tasker"),
"X-NODEDC-User-Email": str(identity.get("email") or ""),
}
if content_type:
headers["Content-Type"] = content_type
if content_length is not None:
headers["Content-Length"] = str(max(0, int(content_length)))
return headers
def bim_gateway_request(method, path, identity=None, params=None, json_payload=None, data=None, content_length=None):
config = get_bim_gateway_config()
try:
response = requests.request(
method,
"{}{}".format(config["internal_base_url"], path),
params=params,
json=json_payload,
data=data,
headers=_gateway_headers(
identity,
content_type="application/octet-stream"
if data is not None
else ("application/json" if json_payload is not None else None),
content_length=content_length,
),
timeout=config["timeout"],
)
except requests.RequestException as exc:
raise BimGatewayError(
"bim_gateway_unavailable",
"NODE.DC BIM gateway is unavailable.",
status_code=502,
) from exc
try:
payload = response.json()
except ValueError:
payload = None
if response.status_code >= 400:
upstream_message = payload.get("message") if isinstance(payload, dict) else None
upstream_error = payload.get("error") if isinstance(payload, dict) else None
status_code = response.status_code if response.status_code in {400, 404, 409, 413, 422} else 502
raise BimGatewayError(
upstream_error or "bim_gateway_rejected",
upstream_message or "NODE.DC BIM gateway rejected the request.",
status_code=status_code,
upstream_status=response.status_code,
)
if not isinstance(payload, dict):
raise BimGatewayError(
"bim_gateway_invalid_response",
"NODE.DC BIM gateway returned an invalid response.",
status_code=502,
upstream_status=response.status_code,
)
return payload
def to_bim_public_url(value):
if not isinstance(value, str) or not value.strip():
return None
config = get_bim_gateway_config()
raw_value = value.strip()
parsed = urlsplit(raw_value)
if parsed.scheme or parsed.netloc:
raw_value = parsed.path
if parsed.query:
raw_value = "{}?{}".format(raw_value, parsed.query)
return urljoin("{}/".format(config["public_base_url"]), raw_value.lstrip("/"))
def to_bim_relative_asset_url(value):
if not isinstance(value, str) or not value.strip():
return None
config = get_bim_gateway_config()
raw_value = value.strip()
parsed = urlsplit(raw_value)
if parsed.scheme or parsed.netloc:
public_origin = urlsplit(config["public_base_url"])
if (parsed.scheme, parsed.netloc) != (public_origin.scheme, public_origin.netloc):
return None
relative = parsed.path
if parsed.query:
relative = "{}?{}".format(relative, parsed.query)
else:
relative = "/{}".format(raw_value.lstrip("/"))
path_only = urlsplit(relative).path
if not (path_only.startswith("/uploads/") or path_only.startswith("/data/")):
return None
return relative
def _model_version_record(beam_viewer, size=0):
conversion = beam_viewer.get("conversion") if isinstance(beam_viewer.get("conversion"), dict) else None
return {
"assetId": beam_viewer.get("assetId"),
"conversion": conversion,
"downloadUrl": beam_viewer.get("downloadUrl"),
"originalFilename": beam_viewer.get("originalFilename"),
"previewAvailable": bool(beam_viewer.get("previewAvailable")),
"projectId": beam_viewer.get("projectId"),
"sha256": beam_viewer.get("sha256"),
"size": int(size or (conversion or {}).get("size") or 0),
"sourceSrc": (conversion or {}).get("sourceSrc") or beam_viewer.get("src"),
"src": beam_viewer.get("src"),
"status": (conversion or {}).get("status") or "ready",
"type": beam_viewer.get("type"),
"uploadedBy": beam_viewer.get("uploadedBy"),
"uploadedAt": beam_viewer.get("uploadedAt"),
"version": int(beam_viewer.get("version") or 1),
"versionId": beam_viewer.get("versionId"),
"viewerUrl": None,
}
def build_bim_attachment(upload_payload, filename, size, uploaded_by, registry_owner_id):
source_src = upload_payload.get("src")
if not isinstance(source_src, str) or not source_src:
raise BimGatewayError(
"bim_gateway_invalid_response",
"NODE.DC BIM gateway did not return an uploaded model path.",
status_code=502,
)
model_type = get_bim_model_type(filename)
direct_type = get_bim_direct_model_type(filename)
conversion = upload_payload.get("conversion")
if isinstance(conversion, dict):
conversion = {
**conversion,
"componentTreeRequired": True,
"message": conversion.get("message")
or "Оригинальная CAD-модель загружена. Просмотр появится после подготовки модели и дерева компонентов.",
"sourceFormat": conversion.get("sourceFormat") or model_type,
"status": conversion.get("status") or "conversion_required",
"targetFormat": conversion.get("targetFormat") or "xkt",
}
else:
conversion = None
source_url = to_bim_public_url(source_src)
beam_viewer = {
"assetId": upload_payload.get("assetId"),
"backend": "beam-viewer-ops",
"downloadUrl": source_url,
"originalFilename": upload_payload.get("originalFilename") or filename,
"previewAvailable": bool(direct_type),
"projectId": upload_payload.get("projectId"),
"registryOwnerId": registry_owner_id,
"sha256": upload_payload.get("sha256"),
"src": source_url,
"type": model_type,
"uploadedBy": str(uploaded_by) if uploaded_by else None,
"uploadedAt": upload_payload.get("uploadedAt"),
"version": int(upload_payload.get("version") or 1),
"versionId": upload_payload.get("versionId"),
"viewerUrl": None,
}
if conversion:
beam_viewer["conversion"] = conversion
beam_viewer["versions"] = [_model_version_record(beam_viewer, size=size)]
return beam_viewer
def merge_bim_versions(previous_beam_viewer, next_beam_viewer):
versions = previous_beam_viewer.get("versions")
versions = list(versions) if isinstance(versions, list) else []
if not versions:
versions.append(_model_version_record(previous_beam_viewer))
next_versions = next_beam_viewer.get("versions")
next_record = (
next_versions[0]
if isinstance(next_versions, list) and next_versions
else _model_version_record(next_beam_viewer)
)
next_version_id = next_record.get("versionId")
next_version = next_record.get("version")
versions = [
version
for version in versions
if not (
(next_version_id and version.get("versionId") == next_version_id)
or (not next_version_id and version.get("version") == next_version)
)
]
versions.append(next_record)
return sorted(versions, key=lambda version: int(version.get("version") or 0))
def find_bim_version(beam_viewer, version_id):
versions = beam_viewer.get("versions")
if not isinstance(versions, list):
versions = []
if version_id:
for version in versions:
if version.get("versionId") == version_id or str(version.get("version")) == str(version_id):
return version
return None
return _model_version_record(beam_viewer)
def resolve_bim_viewer_model(beam_viewer, version_id=None, live_status=None):
record = find_bim_version(beam_viewer, version_id)
if not isinstance(record, dict):
raise BimGatewayError("bim_version_not_found", "BIM model version was not found.", status_code=404)
conversion = record.get("conversion") if isinstance(record.get("conversion"), dict) else {}
if live_status and isinstance(live_status, dict):
conversion = {**conversion, **live_status}
if conversion.get("status") == "ready":
source = conversion.get("artifactUrl") or conversion.get("artifactSrc")
model_type = conversion.get("artifactType") or conversion.get("targetFormat") or "gltf"
settings_source = conversion.get("sourceSrc") or record.get("sourceSrc") or record.get("src")
elif record.get("previewAvailable"):
source = record.get("src")
model_type = record.get("type")
settings_source = record.get("sourceSrc") or record.get("src")
else:
raise BimGatewayError(
"bim_model_not_ready",
"BIM model preview is still being prepared.",
status_code=409,
)
source = to_bim_relative_asset_url(source)
settings_source = to_bim_relative_asset_url(settings_source)
if not source or not settings_source:
raise BimGatewayError(
"bim_model_source_invalid",
"BIM model source is outside the managed BIM storage.",
status_code=422,
)
return {
"url": source,
"settingsSrc": settings_source,
"type": model_type,
"name": record.get("originalFilename") or beam_viewer.get("originalFilename") or "model",
}
def _base64url(value):
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
def build_bim_embed_url(model, user, workspace_slug, project_id, issue_id, attachment_id):
config = get_bim_gateway_config()
if not config["embed_secret"]:
raise BimGatewayError(
"bim_embed_not_configured",
"NODE.DC BIM embed signing secret is not configured.",
status_code=503,
)
now = int(time.time())
payload = {
"v": 1,
"aud": BIM_EMBED_AUDIENCE,
"iss": BIM_EMBED_ISSUER,
"iat": now,
"exp": now + config["embed_ttl"],
"sub": str(getattr(user, "id", "")),
"workspace": str(workspace_slug),
"project": str(project_id),
"issue": str(issue_id),
"attachment": str(attachment_id),
"model": model,
}
encoded_payload = _base64url(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8"))
signature = hmac.new(
config["embed_secret"].encode("utf-8"),
encoded_payload.encode("ascii"),
hashlib.sha256,
).digest()
token = "{}.{}".format(encoded_payload, _base64url(signature))
query = urlencode(
{
"token": token,
"url": model["url"],
"settingsSrc": model["settingsSrc"],
"type": model["type"],
"name": model["name"],
}
)
return "{}/embed/tasker?{}".format(config["public_base_url"], query), payload["exp"]
@@ -4,7 +4,16 @@
* See the LICENSE file for details.
*/
export function MaintenanceMessage() {
export type TMaintenanceReason = "starting" | "offline" | "unavailable";
type TMaintenanceMessageProps = {
autoRetry?: boolean;
reason?: TMaintenanceReason;
statusCode?: number;
};
export function MaintenanceMessage(props: TMaintenanceMessageProps) {
const { autoRetry = false, reason = "unavailable", statusCode } = props;
const linkMap = [
{
key: "mail_to",
@@ -13,24 +22,33 @@ export function MaintenanceMessage() {
},
];
const title =
reason === "starting"
? "Сервисы NODE.DC запускаются."
: reason === "offline"
? "Нет связи с сервером NODE.DC."
: "NODE.DC временно недоступен.";
const description =
reason === "starting"
? "Сервер ещё не готов после обновления или перезапуска. NODE.DC подключится автоматически сразу после завершения запуска. Перезагружать страницу не нужно."
: reason === "offline"
? "Не удалось связаться с сервером. NODE.DC проверяет подключение автоматически и продолжит работу после восстановления связи."
: autoRetry
? "Сервер временно не может обработать запрос. NODE.DC повторяет подключение автоматически и продолжит работу после восстановления."
: "Сервер вернул ошибку, которую нельзя исправить автоматическим повтором. Попробуйте ещё раз или обратитесь в службу поддержки.";
return (
<>
<div className="flex flex-col gap-2.5">
<h1 className="text-left text-18 font-semibold text-primary">&#x1F6A7; NODE.DC запустился с ошибкой.</h1>
<span className="text-left text-14 font-medium text-secondary">
Часть сервисов могла не подняться. Проверьте логи контейнеров и устраните причину. Если нужна помощь,
переходите в службу поддержки.
</span>
<h1 className="text-left text-18 font-semibold text-primary">&#x1F6A7; {title}</h1>
<span className="text-left text-14 font-medium text-secondary">{description}</span>
{statusCode && <span className="text-left text-12 text-tertiary">Код ответа сервера: {statusCode}</span>}
</div>
<div className="mt-1 flex items-center justify-start gap-6">
{linkMap.map((link) => (
<div key={link.key}>
<a
href={link.value}
target="_blank"
rel="noopener noreferrer"
className="nodedc-error-link text-13"
>
<a href={link.value} target="_blank" rel="noopener noreferrer" className="nodedc-error-link text-13">
{link.label}
</a>
</div>
@@ -125,9 +125,9 @@ export const CycleOptions = observer(function CycleOptions(props: CycleOptionsPr
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
return (
<Combobox.Options className="fixed z-10" static>
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
<div
className="nodedc-dropdown-surface z-30 my-1 w-52"
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
@@ -232,9 +232,9 @@ export const EstimateDropdown = observer(function EstimateDropdown(props: Props)
renderByDefault={renderByDefault}
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
<div
className="nodedc-dropdown-surface z-30 my-1 w-52"
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
@@ -269,14 +269,11 @@ export const EstimateDropdown = observer(function EstimateDropdown(props: Props)
<Combobox.Option key={option.value} value={option.value}>
{({ active, selected }) => (
<div
className={cn(
"nodedc-dropdown-option",
{
"bg-white/6": active,
"text-primary": selected,
"text-secondary": !selected,
}
)}
className={cn("nodedc-dropdown-option", {
"bg-white/6": active,
"text-primary": selected,
"text-secondary": !selected,
})}
>
<span className="flex-grow truncate">{option.content}</span>
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
@@ -128,10 +128,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) {
return createPortal(
<Combobox.Options data-prevent-outside-click static>
<div
className={cn(
"nodedc-dropdown-surface z-30 my-1 w-52",
optionsClassName
)}
className={cn("nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52", optionsClassName)}
ref={setPopperElement}
style={{
...styles.popper,
@@ -143,7 +140,7 @@ export const MemberOptions = observer(function MemberOptions(props: Props) {
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
className="w-full bg-transparent py-0 text-12 text-secondary outline-none placeholder:text-placeholder focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("search")}
@@ -113,9 +113,9 @@ export const ModuleOptions = observer(function ModuleOptions(props: Props) {
);
return (
<Combobox.Options className="fixed z-10" static>
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
<div
className="nodedc-dropdown-surface z-30 my-1 w-52"
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
@@ -141,14 +141,11 @@ export const ModuleOptions = observer(function ModuleOptions(props: Props) {
key={option.value}
value={option.value}
className={({ active, selected }) =>
cn(
"nodedc-dropdown-option",
{
"bg-white/6": active,
"text-primary": selected,
"text-secondary": !selected,
}
)
cn("nodedc-dropdown-option", {
"bg-white/6": active,
"text-primary": selected,
"text-secondary": !selected,
})
}
>
{({ selected }) => (
@@ -139,7 +139,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
"clickable block h-full w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none",
buttonContainerClassName
)}
onClick={handleOnClick}
@@ -154,7 +154,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
ref={setReferenceElement}
type="button"
className={cn(
"clickable block h-full max-w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
"clickable block h-full max-w-full rounded-full border-0 bg-transparent shadow-none outline-none focus:outline-none focus-visible:ring-0 focus-visible:outline-none",
{
"cursor-not-allowed text-secondary": disabled,
"cursor-pointer": !disabled,
@@ -189,10 +189,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
<span className="flex-grow truncate text-left">{selectedState?.name ?? t("state")}</span>
)}
{dropdownArrow && (
<ChevronDownIcon
className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)}
aria-hidden="true"
/>
<ChevronDownIcon className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
)}
</>
)}
@@ -214,9 +211,9 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
>
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
<div
className="nodedc-dropdown-surface z-30 my-1 w-52"
className="nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 w-52"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
@@ -226,7 +223,7 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
<Combobox.Input
as="input"
ref={inputRef}
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
className="w-full bg-transparent py-0 text-12 text-secondary outline-none placeholder:text-placeholder focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("common.search.label")}
@@ -5,6 +5,7 @@
*/
import { useTheme } from "next-themes";
import { Button } from "@plane/propel/button";
// assets
import maintenanceModeDarkModeImage from "@/app/assets/instance/maintenance-mode-dark.svg?url";
import maintenanceModeLightModeImage from "@/app/assets/instance/maintenance-mode-light.svg?url";
@@ -12,8 +13,18 @@ import maintenanceModeLightModeImage from "@/app/assets/instance/maintenance-mod
import DefaultLayout from "@/layouts/default-layout";
// components
import { MaintenanceMessage } from "@/plane-web/components/instance";
import type { TMaintenanceReason } from "@/plane-web/components/instance";
export function MaintenanceView() {
type TMaintenanceViewProps = {
autoRetry?: boolean;
isRetrying?: boolean;
reason?: TMaintenanceReason;
statusCode?: number;
onRetry?: () => void;
};
export function MaintenanceView(props: TMaintenanceViewProps) {
const { autoRetry = false, isRetrying = false, reason = "unavailable", statusCode, onRetry } = props;
// hooks
const { resolvedTheme } = useTheme();
// derived values
@@ -31,7 +42,20 @@ export function MaintenanceView() {
/>
</div>
<div className="relative mt-4 flex w-full flex-col gap-4">
<MaintenanceMessage />
<MaintenanceMessage autoRetry={autoRetry} reason={reason} statusCode={statusCode} />
{onRetry && (
<div className="flex justify-start">
<Button
variant="primary"
size="lg"
className="nodedc-error-primary"
loading={isRetrying}
onClick={onRetry}
>
Проверить сейчас
</Button>
</div>
)}
</div>
</div>
</DefaultLayout>
@@ -19,24 +19,21 @@ import { EIssueServiceType } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown, EModalPosition, EModalWidth, ModalCore, Spinner } from "@plane/ui";
import { convertBytesToSize, getFileExtension, getFileName, getFileURL, renderFormattedDate } from "@plane/utils";
import { AlertTriangle, Box, Download, Eye, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
import { AlertTriangle, Box, Download, Eye, FileText, History, ImageIcon, Play, UploadCloud, X } from "lucide-react";
// components
//
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { getFileIcon } from "@/components/icons";
import { MarkdownRenderer } from "@/components/ui/markdown-to-component";
import {
buildBeamViewerUrl,
dispatchBeamViewerOpenEvent,
fetchBeamConversionStatus,
fetchBeamModelVersions,
getBeamModelVersionRecords,
getBeamVersionViewerUrl,
getBeamViewerAttachment,
isBeamModelFile,
mergeBeamModelVersionRecordLists,
syncCurrentBeamVersionRecord,
type TBeamModelVersionRecord,
type TBeamViewerAttachment,
type TBeamConversionStatus,
} from "@/helpers/beam-viewer";
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
@@ -61,6 +58,26 @@ type TIssueAttachmentsListItem = {
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
const VIDEO_EXTENSIONS = new Set(["avi", "m4v", "mov", "mp4", "mpeg", "mpg", "ogv", "webm"]);
const PDF_EXTENSIONS = new Set(["pdf"]);
const MARKDOWN_EXTENSIONS = new Set(["markdown", "md", "mdown", "mkd", "mkdn"]);
const TEXT_EXTENSIONS = new Set([
"cfg",
"conf",
"csv",
"ini",
"json",
"jsonl",
"log",
"ndjson",
"toml",
"tsv",
"txt",
"xml",
"yaml",
"yml",
]);
const MAX_INLINE_TEXT_PREVIEW_SIZE = 8 * 1024 * 1024;
type TAttachmentPreviewType = "image" | "video" | "pdf" | "markdown" | "text" | "file";
const appendSearchParam = (url: string | undefined, key: string, value: string): string => {
if (!url) return "";
@@ -98,11 +115,13 @@ const withBeamViewerSettingsSrc = (
}
};
const getPreviewType = (extension: string) => {
const getPreviewType = (extension: string): TAttachmentPreviewType => {
const normalizedExtension = extension.toLowerCase();
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
if (VIDEO_EXTENSIONS.has(normalizedExtension)) return "video";
if (PDF_EXTENSIONS.has(normalizedExtension)) return "pdf";
if (MARKDOWN_EXTENSIONS.has(normalizedExtension)) return "markdown";
if (TEXT_EXTENSIONS.has(normalizedExtension)) return "text";
return "file";
};
@@ -125,46 +144,6 @@ const sanitizeBeamStatusMessage = (message: string | undefined): string | undefi
const getBeamVersionRecordKey = (version: TBeamModelVersionRecord): string =>
version.versionId || `version-${version.version}`;
const buildSyncedBeamViewerAttachment = (
beamViewer: TBeamViewerAttachment,
status: TBeamConversionStatus,
fullFileName: string
): TBeamViewerAttachment | null => {
if (status.status !== "ready" || !status.artifactUrl) return null;
const artifactType = status.artifactType || status.targetFormat || "gltf";
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
return syncCurrentBeamVersionRecord({
...beamViewer,
assetId: beamViewer.assetId || status.assetId,
previewAvailable: true,
sha256: beamViewer.sha256 || status.sha256,
version: beamViewer.version || status.version,
versionId: beamViewer.versionId || status.versionId,
viewerUrl: buildBeamViewerUrl({
name: fullFileName,
settingsSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc || beamViewer.src,
src: status.artifactUrl,
type: artifactType,
}),
conversion: {
...beamViewer.conversion,
artifactSrc: status.artifactSrc,
artifactType,
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
message: status.message || beamViewer.conversion?.message,
metadataSrc: status.metadataSrc,
size: status.size ?? beamViewer.conversion?.size,
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
status: "ready",
targetFormat,
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
},
});
};
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation();
// props
@@ -213,6 +192,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
type: beamConversionStatus?.artifactType || "gltf",
})
: undefined) || storedModelViewerUrlWithSettings;
const canOpenModelViewer =
!!beamViewer && (beamViewer.previewAvailable || beamEffectiveStatus === "ready" || !!modelViewerUrl);
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
const previewDownloadUrl = modelDownloadUrl || fileURL;
const isBeamModel = isBeamModelFile(fullFileName);
@@ -224,7 +205,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
? "BIM Viewer вернул статус готовности, но не вернул viewer-артефакт."
: null);
const isBeamConversionFailed = beamEffectiveStatus === "failed" || !!beamStatusErrorMessage;
const isBeamAwaitingPreview = !!beamViewer && !modelViewerUrl && !isBeamConversionFailed;
const isBeamAwaitingPreview = !!beamViewer && !canOpenModelViewer && !isBeamConversionFailed;
const rawBeamStatusTooltipContent = isBeamConversionFailed
? beamStatusErrorMessage || beamConversionStatus?.message || "Ошибка подготовки дерева компонентов."
: beamConversionStatus?.message ||
@@ -251,7 +232,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
() => mergeBeamModelVersionRecordLists(localBeamVersions, liveBeamVersions),
[localBeamVersions, liveBeamVersions]
);
const rawVersion = beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version;
const rawVersion =
beamViewer?.version ?? (attachment?.attributes as { version?: number | string } | undefined)?.version;
const versionLabel =
typeof rawVersion === "number"
? `v${rawVersion}`
@@ -268,30 +250,57 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!userId) return "—";
return getUserDetails(userId)?.display_name ?? "—";
};
const openModelViewer = () => {
if (!modelViewerUrl) return;
dispatchBeamViewerOpenEvent({
downloadUrl: modelDownloadUrl,
fileExtension,
fileName: fullFileName,
fileSize: attachment?.attributes.size ?? 0,
issueId,
viewerUrl: modelViewerUrl,
});
const openModelViewer = async () => {
if (!canOpenModelViewer || !attachment) return;
try {
const viewerUrl = await attachmentService.getBeamIssueAttachmentViewerUrl(
workspaceSlug,
projectId,
issueId,
attachment.id
);
dispatchBeamViewerOpenEvent({
downloadUrl: modelDownloadUrl,
fileExtension,
fileName: fullFileName,
fileSize: attachment.attributes.size ?? 0,
issueId,
viewerUrl,
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Модель не открыта",
message: error instanceof Error ? error.message : "Не удалось запустить BIM Viewer через Ops.",
});
}
};
const openBeamVersionViewer = (version: TBeamModelVersionRecord) => {
const viewerUrl = getBeamVersionViewerUrl(version);
if (!viewerUrl) return;
setIsVersionHistoryOpen(false);
dispatchBeamViewerOpenEvent({
downloadUrl: version.downloadUrl,
fileExtension: getFileExtension(version.originalFilename),
fileName: version.originalFilename,
fileSize: version.size || version.conversion?.size || 0,
issueId,
viewerUrl,
});
const openBeamVersionViewer = async (version: TBeamModelVersionRecord) => {
if (!attachment || !getBeamVersionViewerUrl(version)) return;
try {
const viewerUrl = await attachmentService.getBeamIssueAttachmentViewerUrl(
workspaceSlug,
projectId,
issueId,
attachment.id,
version.versionId || String(version.version)
);
setIsVersionHistoryOpen(false);
dispatchBeamViewerOpenEvent({
downloadUrl: version.downloadUrl,
fileExtension: getFileExtension(version.originalFilename),
fileName: version.originalFilename,
fileSize: version.size || version.conversion?.size || 0,
issueId,
viewerUrl,
});
} catch (error) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Версия не открыта",
message: error instanceof Error ? error.message : "Не удалось запустить версию BIM-модели.",
});
}
};
const startVersionUpload = () => {
versionUploadInputRef.current?.click();
@@ -362,7 +371,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
}
};
const menuItems: TContextMenuItem[] = [
...(modelViewerUrl
...(canOpenModelViewer
? [
{
key: "view-model",
@@ -429,7 +438,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
}
let isMounted = true;
fetchBeamModelVersions(beamViewer)
attachmentService
.getBeamIssueAttachmentVersions(workspaceSlug, projectId, issueId, attachmentId)
.then((history) => {
if (!isMounted) return;
setLiveBeamVersions(Array.isArray(history.versions) ? history.versions : []);
@@ -443,7 +453,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
return () => {
isMounted = false;
};
}, [beamViewer?.assetId, beamViewer?.projectId, beamViewer?.src, beamViewer?.versionId]);
}, [
attachmentId,
attachmentService,
beamViewer?.assetId,
beamViewer?.projectId,
beamViewer?.src,
beamViewer?.versionId,
issueId,
projectId,
workspaceSlug,
]);
useEffect(() => {
if (!beamViewer || !beamViewer.src) return;
@@ -454,17 +474,15 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const pollStatus = async () => {
try {
const status = await fetchBeamConversionStatus(beamViewer);
const status = await attachmentService.getBeamIssueAttachmentStatus(
workspaceSlug,
projectId,
issueId,
attachmentId
);
if (!isMounted) return;
setBeamConversionError(null);
setBeamConversionStatus(status);
const syncedBeamViewer = buildSyncedBeamViewerAttachment(beamViewer, status, fullFileName);
if (syncedBeamViewer && syncedBeamViewer.viewerUrl !== beamViewer.viewerUrl) {
attachmentService
.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachmentId, syncedBeamViewer)
.then(() => fetchAttachments(workspaceSlug, projectId, issueId))
.catch((error) => console.error("Error in syncing Beam attachment metadata:", error));
}
if (status.status !== "ready" && status.status !== "failed") {
timeoutId = window.setTimeout(pollStatus, 5000);
}
@@ -481,17 +499,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
isMounted = false;
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [
attachmentId,
attachmentService,
beamViewer,
fetchAttachments,
fullFileName,
issueId,
projectId,
storedModelViewerUrl,
workspaceSlug,
]);
}, [attachmentId, attachmentService, beamViewer, issueId, projectId, storedModelViewerUrl, workspaceSlug]);
if (!attachment) return <></>;
@@ -506,7 +514,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (modelViewerUrl) openModelViewer();
if (canOpenModelViewer) void openModelViewer();
else setIsPreviewOpen(true);
}}
>
@@ -530,6 +538,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
) : (
<Box className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "markdown" || previewType === "text" ? (
<FileText className="size-5 text-[rgb(var(--nodedc-accent-rgb))]" />
) : previewType === "file" ? (
getFileIcon(fileExtension, 18)
) : (
@@ -586,9 +596,9 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</button>
</Tooltip>
)}
{(modelViewerUrl || previewURL) && (
{(canOpenModelViewer || previewURL) && (
<Tooltip
tooltipContent={modelViewerUrl ? "Посмотреть модель" : "Открыть предпросмотр"}
tooltipContent={canOpenModelViewer ? "Посмотреть модель" : "Открыть предпросмотр"}
isMobile={isMobile}
>
<button
@@ -597,7 +607,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (modelViewerUrl) openModelViewer();
if (canOpenModelViewer) void openModelViewer();
else setIsPreviewOpen(true);
}}
>
@@ -683,7 +693,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (modelViewerUrl) openModelViewer();
if (canOpenModelViewer) void openModelViewer();
else setIsPreviewOpen(true);
}}
>
@@ -727,6 +737,8 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
) : (
<Box className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
)
) : previewType === "markdown" || previewType === "text" ? (
<FileText className="size-9 text-[rgb(var(--nodedc-accent-rgb))]" />
) : previewType === "file" ? (
fileIcon
) : (
@@ -819,7 +831,7 @@ type TAttachmentPreviewContent = {
isBeamConversionFailed: boolean;
modelDownloadUrl: string | undefined;
previewDownloadUrl: string | undefined;
previewType: "image" | "video" | "pdf" | "file";
previewType: TAttachmentPreviewType;
previewURL: string;
setIsPreviewOpen: (isOpen: boolean) => void;
size: number;
@@ -883,6 +895,13 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
</div>
) : previewType === "pdf" && previewURL ? (
<IssueAttachmentPdfPreview fileURL={previewURL} />
) : (previewType === "markdown" || previewType === "text") && previewURL ? (
<AttachmentTextPreview
fileName={fullFileName}
fileURL={previewURL}
isMarkdown={previewType === "markdown"}
size={size}
/>
) : isBeamAwaitingPreview || isBeamConversionFailed ? (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div
@@ -938,6 +957,93 @@ const AttachmentPreviewContent = (props: TAttachmentPreviewContent) => {
);
};
type TAttachmentTextPreview = {
fileName: string;
fileURL: string;
isMarkdown: boolean;
size: number;
};
const AttachmentTextPreview = (props: TAttachmentTextPreview) => {
const { fileName, fileURL, isMarkdown, size } = props;
const [content, setContent] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (size > MAX_INLINE_TEXT_PREVIEW_SIZE) {
setContent(null);
setError(`Файл больше ${convertBytesToSize(MAX_INLINE_TEXT_PREVIEW_SIZE)}. Скачайте его для просмотра.`);
return;
}
const controller = new AbortController();
setContent(null);
setError(null);
fetch(fileURL, {
cache: "no-store",
credentials: "include",
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then((text) => setContent(text))
.catch((fetchError) => {
if (controller.signal.aborted) return;
console.error("Error in loading text attachment preview:", fetchError);
setError("Не удалось загрузить содержимое файла для предпросмотра.");
});
return () => controller.abort();
}, [fileURL, size]);
const renderedContent = useMemo(() => {
if (content === null || isMarkdown) return content;
const extension = getFileExtension(fileName).toLowerCase();
if (extension !== "json") return content;
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch (_error) {
return content;
}
}, [content, fileName, isMarkdown]);
if (error) {
return (
<div className="flex h-full items-center justify-center p-8 text-center text-14 text-secondary">{error}</div>
);
}
if (renderedContent === null) {
return (
<div className="flex h-full items-center justify-center">
<Spinner
height="34px"
width="34px"
className="fill-[rgb(var(--nodedc-accent-rgb))] text-[rgba(var(--nodedc-accent-rgb),0.18)]"
/>
</div>
);
}
return (
<div className="vertical-scrollbar h-full overflow-auto bg-surface-1 px-6 py-5 sm:px-10 sm:py-8">
{isMarkdown ? (
<article className="nodedc-attachment-markdown-preview mx-auto max-w-4xl">
<MarkdownRenderer markdown={renderedContent} />
</article>
) : (
<pre className="font-mono m-0 min-h-full text-13 leading-6 break-words whitespace-pre-wrap text-secondary">
{renderedContent}
</pre>
)}
</div>
);
};
type TBeamVersionHistoryModal = {
currentVersionId: string | undefined;
deletingVersionKey: string | null;
@@ -252,9 +252,9 @@ export function LabelDropdown(props: ILabelDropdownProps) {
multiple
>
{isOpen && (
<Combobox.Options className="fixed z-10" static>
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
<div
className={`nodedc-dropdown-surface z-10 my-1 h-auto w-52 whitespace-nowrap ${optionsClassName}`}
className={`nodedc-dropdown-surface nodedc-external-popup-anchor z-[760] my-1 h-auto w-52 whitespace-nowrap ${optionsClassName}`}
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
@@ -263,7 +263,7 @@ export function LabelDropdown(props: ILabelDropdownProps) {
<SearchIcon className="h-3.5 w-3.5 text-tertiary" />
<Combobox.Input
ref={inputRef}
className="w-full bg-transparent px-0 py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
className="w-full bg-transparent px-0 py-0 text-12 text-secondary outline-none placeholder:text-placeholder focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("common.search.label")}
@@ -10,14 +10,16 @@ import {
useRef,
useState,
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type ReactNode,
} from "react";
import { observer } from "mobx-react";
import { createPortal } from "react-dom";
import { Download, Maximize2, Minimize2, X } from "lucide-react";
import { Download, Maximize2, Minimize2, UploadCloud, X } from "lucide-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
import { cn, convertBytesToSize } from "@plane/utils";
@@ -26,6 +28,7 @@ import { BEAM_VIEWER_OPEN_EVENT, type TBeamViewerOpenEventDetail } from "@/helpe
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import useKeypress from "@/hooks/use-keypress";
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
// local imports
import type { TIssueOperations } from "../issue-detail";
import { IssueActivity } from "../issue-detail/issue-activity";
@@ -40,6 +43,8 @@ import { PeekOverviewProperties } from "./properties";
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:issue-peek-width";
const BEAM_VIEWER_CLOSING_CLASS_NAME = "nodedc-beam-viewer-closing";
const isFileDragEvent = (event: ReactDragEvent<HTMLElement>) => Array.from(event.dataTransfer.types).includes("Files");
interface IIssueView {
workspaceSlug: string;
projectId: string;
@@ -96,6 +101,8 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const [beamPeekViewer, setBeamPeekViewer] = useState<TBeamViewerOpenEventDetail | null>(null);
const [isBeamPeekFullscreen, setIsBeamPeekFullscreen] = useState(false);
const [isBeamPeekClosing, setIsBeamPeekClosing] = useState(false);
const [isCardAttachmentUploading, setIsCardAttachmentUploading] = useState(false);
const [isCardAttachmentDragActive, setIsCardAttachmentDragActive] = useState(false);
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
if (typeof window === "undefined") return 720;
@@ -114,17 +121,158 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
const initialMouseXRef = useRef<number>(0);
const livePeekWidthRef = useRef<number>(sidePeekWidth);
const beamCloseTimeoutRef = useRef<number | null>(null);
const cardAttachmentDragDepthRef = useRef(0);
// store hooks
const {
setPeekIssue,
isAnyModalOpen,
createAttachment,
fetchActivities,
fetchAttachments,
issue: { getIssueById },
} = useIssueDetail();
const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS);
const { fileSizeLimitEnabled, maxFileSize } = useFileSize();
const issue = getIssueById(issueId);
const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout;
const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout;
const isCardAttachmentDropDisabled =
disabled || is_archived || !!isLoading || !!isError || !issue || isCardAttachmentUploading;
const handleCardAttachmentDrop = useCallback(
async (acceptedFiles: File[], rejectedFileCount = 0) => {
if (acceptedFiles.length === 0) {
if (rejectedFileCount > 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Файлы не прикреплены",
message: fileSizeLimitEnabled
? `Проверьте размер файлов: максимум ${Math.round(maxFileSize / 1024 / 1024)} МБ на файл.`
: "Не удалось принять выбранные файлы.",
});
}
return;
}
setIsCardAttachmentUploading(true);
try {
const uploadResults = await Promise.allSettled(
acceptedFiles.map((file) => createAttachment(workspaceSlug, projectId, issueId, file))
);
const failedUploads = uploadResults.filter((result) => result.status === "rejected");
await Promise.allSettled([
fetchAttachments(workspaceSlug, projectId, issueId),
fetchActivities(workspaceSlug, projectId, issueId),
]);
if (failedUploads.length > 0 || rejectedFileCount > 0) {
const failedCount = failedUploads.length + rejectedFileCount;
setToast({
type: TOAST_TYPE.ERROR,
title:
failedCount === acceptedFiles.length + rejectedFileCount ? "Файлы не прикреплены" : "Загружено не всё",
message: `${failedCount} ${failedCount === 1 ? "файл не удалось прикрепить" : "файла не удалось прикрепить"}.`,
});
} else {
setToast({
type: TOAST_TYPE.SUCCESS,
title: acceptedFiles.length === 1 ? "Файл прикреплён" : "Файлы прикреплены",
message:
acceptedFiles.length === 1
? (acceptedFiles[0]?.name ?? "Вложение добавлено в карточку.")
: `${acceptedFiles.length} файлов добавлено в карточку.`,
});
}
} finally {
setIsCardAttachmentUploading(false);
}
},
[
createAttachment,
fetchActivities,
fetchAttachments,
fileSizeLimitEnabled,
issueId,
maxFileSize,
projectId,
workspaceSlug,
]
);
const resetCardAttachmentDrag = useCallback(() => {
cardAttachmentDragDepthRef.current = 0;
setIsCardAttachmentDragActive(false);
}, []);
useEffect(() => {
window.addEventListener("dragend", resetCardAttachmentDrag, true);
window.addEventListener("drop", resetCardAttachmentDrag, true);
window.addEventListener("blur", resetCardAttachmentDrag);
return () => {
window.removeEventListener("dragend", resetCardAttachmentDrag, true);
window.removeEventListener("drop", resetCardAttachmentDrag, true);
window.removeEventListener("blur", resetCardAttachmentDrag);
};
}, [resetCardAttachmentDrag]);
const handleCardDragEnterCapture = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
event.preventDefault();
event.stopPropagation();
cardAttachmentDragDepthRef.current += 1;
setIsCardAttachmentDragActive(true);
},
[isCardAttachmentDropDisabled]
);
const handleCardDragOverCapture = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setIsCardAttachmentDragActive(true);
},
[isCardAttachmentDropDisabled]
);
const handleCardDragLeaveCapture = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (isCardAttachmentDropDisabled || !isFileDragEvent(event)) return;
event.preventDefault();
event.stopPropagation();
cardAttachmentDragDepthRef.current = Math.max(0, cardAttachmentDragDepthRef.current - 1);
if (cardAttachmentDragDepthRef.current === 0) setIsCardAttachmentDragActive(false);
},
[isCardAttachmentDropDisabled]
);
const handleCardDropCapture = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDragEvent(event)) return;
event.preventDefault();
event.stopPropagation();
resetCardAttachmentDrag();
if (isCardAttachmentDropDisabled) return;
const droppedFiles = Array.from(event.dataTransfer.files);
const acceptedFiles = fileSizeLimitEnabled
? droppedFiles.filter((file) => file.size <= maxFileSize)
: droppedFiles;
const rejectedFileCount = droppedFiles.length - acceptedFiles.length;
void handleCardAttachmentDrop(acceptedFiles, rejectedFileCount);
},
[fileSizeLimitEnabled, handleCardAttachmentDrop, isCardAttachmentDropDisabled, maxFileSize, resetCardAttachmentDrag]
);
// remove peek id
const removeRoutePeekId = () => {
setPeekIssue(undefined);
@@ -407,7 +555,32 @@ export const IssueView = observer(function IssueView(props: IIssueView) {
};
const issuePanel = issueId ? (
<div ref={issuePeekOverviewRef} className={peekOverviewIssueClassName} style={issuePanelStyle}>
<div
ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName}
style={issuePanelStyle}
data-card-attachment-drop-active={isCardAttachmentDragActive ? "true" : "false"}
onDragEnterCapture={handleCardDragEnterCapture}
onDragOverCapture={handleCardDragOverCapture}
onDragLeaveCapture={handleCardDragLeaveCapture}
onDropCapture={handleCardDropCapture}
>
{isCardAttachmentDragActive && (
<div className="pointer-events-none absolute inset-0 z-[96] flex items-center justify-center overflow-hidden rounded-[inherit] bg-surface-2/88 p-6 backdrop-blur-md">
<div className="absolute inset-3 rounded-[22px] border border-dashed border-[#303432] bg-white/[0.01]" />
<div className="relative flex max-w-md flex-col items-center text-center">
<div className="grid size-16 place-items-center rounded-3xl bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]">
<UploadCloud className="size-7" />
</div>
<div className="text-17 mt-5 font-semibold text-primary">Отпустите файлы, чтобы прикрепить</div>
<div className="mt-2 text-13 text-secondary">
{fileSizeLimitEnabled
? `Можно несколько файлов за раз, до ${Math.round(maxFileSize / 1024 / 1024)} МБ каждый`
: "Можно прикрепить несколько файлов за раз"}
</div>
</div>
</div>
)}
{shouldAllowPeekResize && peekMode === "side-peek" && (
<div
className="absolute top-0 left-0 z-[81] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
@@ -5,6 +5,7 @@
*/
import type { ReactNode } from "react";
import { isAxiosError } from "axios";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
@@ -17,16 +18,44 @@ type TInstanceWrapper = {
children: ReactNode;
};
const INSTANCE_RETRY_BASE_INTERVAL = 2_000;
const INSTANCE_RETRY_MAX_INTERVAL = 30_000;
export const getInstanceRetryInterval = (retryCount: number) =>
Math.min(INSTANCE_RETRY_MAX_INTERVAL, INSTANCE_RETRY_BASE_INTERVAL * 2 ** Math.min(Math.max(retryCount - 1, 0), 4));
export const getInstanceErrorStatus = (error: unknown) => (isAxiosError(error) ? error.response?.status : undefined);
export const shouldRetryInstanceRequest = (error: unknown) => {
if (!isAxiosError(error) || !error.response) return true;
const { status } = error.response;
return status === 408 || status === 429 || status >= 500;
};
const InstanceWrapper = observer(function InstanceWrapper(props: TInstanceWrapper) {
const { children } = props;
// store
const { isLoading, instance, error, fetchInstanceInfo } = useInstance();
const { isLoading: isInstanceSWRLoading, error: instanceSWRError } = useSWR(
"INSTANCE_INFORMATION",
async () => await fetchInstanceInfo(),
{ revalidateOnFocus: false }
);
const {
isLoading: isInstanceSWRLoading,
isValidating: isInstanceSWRValidating,
error: instanceSWRError,
mutate: retryInstanceInfo,
} = useSWR("INSTANCE_INFORMATION", async () => await fetchInstanceInfo(), {
revalidateOnFocus: true,
revalidateOnReconnect: true,
shouldRetryOnError: true,
onErrorRetry: (requestError, _key, _config, revalidate, { retryCount }) => {
if (typeof navigator !== "undefined" && !navigator.onLine) return;
if (!shouldRetryInstanceRequest(requestError)) return;
setTimeout(() => {
void revalidate({ retryCount });
}, getInstanceRetryInterval(retryCount));
},
});
// loading state
if ((isLoading || isInstanceSWRLoading) && !instance)
@@ -36,7 +65,27 @@ const InstanceWrapper = observer(function InstanceWrapper(props: TInstanceWrappe
</div>
);
if (instanceSWRError) return <MaintenanceView />;
if (instanceSWRError) {
const statusCode = getInstanceErrorStatus(instanceSWRError);
const reason =
statusCode === 502 || statusCode === 503 || statusCode === 504
? "starting"
: statusCode === undefined
? "offline"
: "unavailable";
return (
<MaintenanceView
autoRetry={shouldRetryInstanceRequest(instanceSWRError)}
isRetrying={isInstanceSWRValidating}
reason={reason}
statusCode={statusCode}
onRetry={() => {
void retryInstanceInfo();
}}
/>
);
}
// something went wrong while in the request
if (error && error?.status === "error") return <>{children}</>;
@@ -9,7 +9,6 @@ import { API_BASE_URL } from "@plane/constants";
// plane types
import { getFileMetaDataForUpload, generateFileUploadPayload } from "@plane/services";
import type {
TFileMetaDataLite,
TIssueAttachment,
TIssueAttachmentUploadResponse,
TIssueServiceType,
@@ -17,17 +16,11 @@ import type {
import { EIssueServiceType } from "@plane/types";
// services
import {
deleteBeamModelAsset,
deleteBeamModelVersion,
getBeamModelVersionRecords,
getBeamModelMimeType,
getBeamViewerAttachment,
getBeamViewerVersionNumber,
isBeamModelFile,
mergeBeamModelVersionRecords,
type TBeamModelVersionRecord,
type TBeamViewerAttachment,
uploadBeamModelFile,
type TBeamConversionStatus,
type TBeamVersionHistoryResponse,
} from "@/helpers/beam-viewer";
import { APIService } from "@/services/api.service";
import { FileUploadService } from "@/services/file-upload.service";
@@ -49,6 +42,15 @@ export class IssueAttachmentService extends APIService {
this.serviceType = serviceType;
}
private attachmentBasePath(workspaceSlug: string, projectId: string, issueId: string): string {
return `/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments`;
}
private toBeamRequestError(error: unknown, fallback: string): Error {
const responseData = (error as { response?: { data?: { message?: string; error?: string } } })?.response?.data;
return new Error(responseData?.message || responseData?.error || fallback);
}
private async updateIssueAttachmentUploadStatus(
workspaceSlug: string,
projectId: string,
@@ -64,43 +66,6 @@ export class IssueAttachmentService extends APIService {
});
}
private async createBeamIssueAttachmentReference(
workspaceSlug: string,
projectId: string,
issueId: string,
payload: TFileMetaDataLite & { beamViewer: TBeamViewerAttachment }
): Promise<TIssueAttachment> {
return this.post(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/`,
payload
)
.then((response) => {
const createResponse: TIssueAttachmentCreateResponse = response?.data;
return createResponse.attachment;
})
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async updateBeamIssueAttachmentReference(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string,
beamViewer: TBeamViewerAttachment,
attributes: Record<string, unknown> = {}
): Promise<TIssueAttachment> {
return this.patch(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
{ ...attributes, beamViewer }
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async uploadBeamIssueAttachmentVersion(
workspaceSlug: string,
projectId: string,
@@ -108,60 +73,26 @@ export class IssueAttachmentService extends APIService {
attachment: TIssueAttachment,
file: File,
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"],
uploadedBy?: string
_uploadedBy?: string
): Promise<TIssueAttachment> {
const previousBeamViewer = getBeamViewerAttachment(attachment);
if (!previousBeamViewer) {
if (!getBeamViewerAttachment(attachment)) {
throw new Error("Для этого вложения нет BIM-метаданных.");
}
if (!isBeamModelFile(file.name)) {
throw new Error("Формат модели не поддерживается BIM Viewer.");
}
const previousVersions = getBeamModelVersionRecords(previousBeamViewer, attachment);
const currentVersion = Math.max(
getBeamViewerVersionNumber(previousBeamViewer, attachment),
...previousVersions.map((version) => version.version)
);
const nextVersion = currentVersion + 1;
const assetId = previousBeamViewer.assetId || attachment.id;
const nextBeamViewer = await uploadBeamModelFile(file, {
assetId,
issueId,
onUploadProgress: uploadProgressHandler,
projectId,
uploadedBy,
version: nextVersion,
workspaceSlug,
});
const nextVersions = mergeBeamModelVersionRecords(
{ ...previousBeamViewer, assetId, versions: previousVersions },
nextBeamViewer,
attachment
);
return this.updateBeamIssueAttachmentReference(
workspaceSlug,
projectId,
issueId,
attachment.id,
{
...nextBeamViewer,
assetId,
projectId: nextBeamViewer.projectId || previousBeamViewer.projectId,
previewAvailable: nextBeamViewer.previewAvailable,
uploadedBy,
version: nextVersion,
versions: nextVersions,
viewerUrl: nextBeamViewer.viewerUrl ?? null,
},
{
name: file.name,
size: file.size,
type: getBeamModelMimeType(file),
version: nextVersion,
}
);
const formData = new FormData();
formData.append("file", file);
return this.post(
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachment.id}/bim-versions/`,
formData,
{ onUploadProgress: uploadProgressHandler }
)
.then((response) => response?.data)
.catch((error) => {
throw this.toBeamRequestError(error, "Не удалось загрузить новую версию BIM-модели.");
});
}
async deleteBeamIssueAttachmentVersion(
@@ -171,36 +102,69 @@ export class IssueAttachmentService extends APIService {
attachment: TIssueAttachment,
versionToDelete: TBeamModelVersionRecord
): Promise<TIssueAttachment> {
const beamViewer = getBeamViewerAttachment(attachment);
if (!beamViewer) {
if (!getBeamViewerAttachment(attachment)) {
throw new Error("Для этого вложения нет BIM-метаданных.");
}
const versionKey = encodeURIComponent(versionToDelete.versionId || String(versionToDelete.version));
return this.delete(
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachment.id}/bim-versions/${versionKey}/`
)
.then((response) => response?.data)
.catch((error) => {
throw this.toBeamRequestError(error, "Не удалось удалить версию BIM-модели.");
});
}
const currentVersion = getBeamViewerVersionNumber(beamViewer, attachment);
const isCurrentVersion = versionToDelete.versionId
? beamViewer.versionId === versionToDelete.versionId
: currentVersion === versionToDelete.version;
if (isCurrentVersion) {
throw new Error("Текущую версию нельзя удалить. Сначала переключите модель на другую версию.");
}
async getBeamIssueAttachmentStatus(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string
): Promise<TBeamConversionStatus> {
return this.get(
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-status/`
)
.then((response) => response?.data)
.catch((error) => {
throw this.toBeamRequestError(error, "Не удалось получить статус BIM-модели.");
});
}
const versions = getBeamModelVersionRecords(beamViewer, attachment);
const nextVersions = versions.filter((version) =>
versionToDelete.versionId
? version.versionId !== versionToDelete.versionId
: version.version !== versionToDelete.version
);
async getBeamIssueAttachmentVersions(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string
): Promise<TBeamVersionHistoryResponse> {
return this.get(
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-versions/`
)
.then((response) => response?.data)
.catch((error) => {
throw this.toBeamRequestError(error, "Не удалось получить историю BIM-модели.");
});
}
if (nextVersions.length === versions.length) {
throw new Error("Версия не найдена.");
}
await deleteBeamModelVersion(versionToDelete);
return this.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachment.id, {
...beamViewer,
versions: nextVersions,
});
async getBeamIssueAttachmentViewerUrl(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string,
versionId?: string
): Promise<string> {
return this.post(
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/${attachmentId}/bim-viewer/`,
versionId ? { versionId } : {}
)
.then((response) => {
const viewerUrl = response?.data?.viewerUrl;
if (!viewerUrl) throw new Error("Ops не вернул ссылку запуска BIM Viewer.");
return viewerUrl as string;
})
.catch((error) => {
if (error instanceof Error && !("response" in error)) throw error;
throw this.toBeamRequestError(error, "Не удалось открыть BIM-модель.");
});
}
async uploadIssueAttachment(
@@ -211,19 +175,20 @@ export class IssueAttachmentService extends APIService {
uploadProgressHandler?: AxiosRequestConfig["onUploadProgress"]
): Promise<TIssueAttachment> {
if (isBeamModelFile(file.name)) {
const beamViewer = await uploadBeamModelFile(file, {
issueId,
onUploadProgress: uploadProgressHandler,
projectId,
workspaceSlug,
});
return this.createBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, {
beamViewer,
name: file.name,
size: file.size,
type: getBeamModelMimeType(file),
});
const formData = new FormData();
formData.append("file", file);
return this.post(
`${this.attachmentBasePath(workspaceSlug, projectId, issueId)}/bim-upload/`,
formData,
{ onUploadProgress: uploadProgressHandler }
)
.then((response) => {
const createResponse: TIssueAttachmentCreateResponse = response?.data;
return createResponse.attachment;
})
.catch((error) => {
throw this.toBeamRequestError(error, "Не удалось загрузить модель через Ops.");
});
}
const fileMetaData = await getFileMetaDataForUpload(file);
@@ -278,15 +243,8 @@ export class IssueAttachmentService extends APIService {
projectId: string,
issueId: string,
assetId: string,
attachment?: TIssueAttachment
_attachment?: TIssueAttachment
): Promise<TIssueAttachment> {
const beamViewer = getBeamViewerAttachment(attachment);
if (beamViewer) {
await deleteBeamModelAsset(beamViewer).catch((error) => {
console.warn("BIM storage cleanup failed; deleting OPS attachment reference anyway.", error);
});
}
return this.delete(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${assetId}/`
)
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
DEFAULT_BEAM_VIEWER_BASE_URL,
getBeamDirectModelTypeFromFileName,
getBeamModelTypeFromFileName,
// @ts-expect-error Node's strip-types test runner requires the explicit TypeScript extension.
} from "./beam-viewer-config.ts";
test("routes STEP and IGES variants through the converter", () => {
assert.equal(getBeamModelTypeFromFileName("model.step"), "step");
assert.equal(getBeamModelTypeFromFileName("model.STP"), "step");
assert.equal(getBeamModelTypeFromFileName("model.iges"), "iges");
assert.equal(getBeamModelTypeFromFileName("model.IGS"), "iges");
assert.equal(getBeamDirectModelTypeFromFileName("model.igs"), null);
});
test("uses the production BIM service when build args are absent", () => {
assert.equal(DEFAULT_BEAM_VIEWER_BASE_URL, "https://bim.nodedc.tech");
});
@@ -0,0 +1,35 @@
export const DEFAULT_BEAM_VIEWER_BASE_URL = "https://bim.nodedc.tech";
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
bim: "bim",
glb: "gltf",
gltf: "gltf",
las: "las",
laz: "las",
obj: "obj",
stl: "stl",
xkt: "xkt",
};
const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
iges: "iges",
igs: "iges",
step: "step",
stp: "step",
};
export const getBeamFileExtension = (name: string): string => {
const parts = name.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
};
export const getBeamModelTypeFromFileName = (name: string | undefined): string | null => {
if (!name) return null;
const extension = getBeamFileExtension(name);
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[extension] ?? BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION[extension] ?? null;
};
export const getBeamDirectModelTypeFromFileName = (name: string | undefined): string | null => {
if (!name) return null;
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[getBeamFileExtension(name)] ?? null;
};
+12 -237
View File
@@ -1,5 +1,10 @@
import type { AxiosProgressEvent } from "axios";
import type { TIssueAttachment } from "@plane/types";
import {
DEFAULT_BEAM_VIEWER_BASE_URL,
getBeamDirectModelTypeFromFileName,
getBeamFileExtension,
getBeamModelTypeFromFileName,
} from "./beam-viewer-config";
export type TBeamViewerConversion = {
artifactSrc?: string;
@@ -37,12 +42,13 @@ export type TBeamModelVersionRecord = {
export type TBeamViewerAttachment = {
assetId?: string;
backend: "beam-viewer-local" | "beam-viewer";
backend: "beam-viewer-local" | "beam-viewer-ops" | "beam-viewer";
conversion?: TBeamViewerConversion;
downloadUrl: string;
originalFilename: string;
previewAvailable: boolean;
projectId?: string;
registryOwnerId?: string;
sha256?: string;
src: string;
type: string;
@@ -101,24 +107,6 @@ export const dispatchBeamViewerOpenEvent = (detail: TBeamViewerOpenEventDetail)
window.dispatchEvent(new CustomEvent<TBeamViewerOpenEventDetail>(BEAM_VIEWER_OPEN_EVENT, { detail }));
};
const DEFAULT_BEAM_VIEWER_BASE_URL = "http://localhost:8080";
const BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
bim: "bim",
glb: "gltf",
gltf: "gltf",
las: "las",
laz: "las",
obj: "obj",
stl: "stl",
xkt: "xkt",
};
const BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION: Record<string, string> = {
step: "step",
stp: "step",
};
const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
glb: "model/gltf-binary",
gltf: "model/gltf+json",
@@ -127,35 +115,13 @@ const MIME_TYPE_BY_EXTENSION: Record<string, string> = {
const normalizeBaseUrl = (url: string | undefined): string =>
(url && url.trim() ? url.trim() : DEFAULT_BEAM_VIEWER_BASE_URL).replace(/\/+$/, "");
const getFileExtension = (name: string): string => {
const parts = name.split(".");
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : "";
};
const getUploadGroupId = (parts: Array<string | undefined>): string => {
const value = parts
.filter(Boolean)
.join("_")
.replace(/[^a-zA-Z0-9_-]/g, "_");
return value || "tasker";
};
const toBeamRelativeUploadSrc = (src: string): string => {
try {
const url = new URL(src);
return url.pathname.replace(/^\/+/, "");
} catch (_error) {
return src.replace(/^\/+/, "");
}
};
const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string | undefined => {
if (!src) return undefined;
let url: URL;
try {
url = new URL(src);
} catch (_error) {
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamApiBaseUrl()}/`);
url = new URL(src.startsWith("/") ? src : `/${src}`, `${getBeamViewerBaseUrl()}/`);
}
if (cacheKey) url.searchParams.set("v", cacheKey);
return url.toString();
@@ -163,22 +129,16 @@ const toBeamAbsoluteUrl = (src: string | undefined, cacheKey?: string): string |
export const getBeamViewerBaseUrl = (): string => normalizeBaseUrl(process.env.VITE_BEAM_VIEWER_BASE_URL);
export const getBeamApiBaseUrl = (): string =>
normalizeBaseUrl(process.env.VITE_BEAM_API_BASE_URL || process.env.VITE_BEAM_VIEWER_BASE_URL);
export const getBeamModelTypeFromName = (name: string | undefined): string | null => {
if (!name) return null;
const extension = getFileExtension(name);
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[extension] ?? BEAM_CONVERTIBLE_MODEL_TYPE_BY_EXTENSION[extension] ?? null;
return getBeamModelTypeFromFileName(name);
};
export const getBeamDirectModelTypeFromName = (name: string | undefined): string | null => {
if (!name) return null;
return BEAM_DIRECT_MODEL_TYPE_BY_EXTENSION[getFileExtension(name)] ?? null;
return getBeamDirectModelTypeFromFileName(name);
};
export const getBeamModelMimeType = (file: File): string =>
MIME_TYPE_BY_EXTENSION[getFileExtension(file.name)] || "application/octet-stream";
MIME_TYPE_BY_EXTENSION[getBeamFileExtension(file.name)] || "application/octet-stream";
export const isBeamModelFile = (name: string | undefined): boolean => !!getBeamModelTypeFromName(name);
@@ -337,188 +297,3 @@ export const getBeamVersionViewerUrl = (version: TBeamModelVersionRecord): strin
type: artifactType,
});
};
export const fetchBeamConversionStatus = async (beamViewer: TBeamViewerAttachment): Promise<TBeamConversionStatus> => {
const statusUrl = new URL("/api/conversions/status", `${getBeamApiBaseUrl()}/`);
statusUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
const response = await fetch(statusUrl.toString(), { credentials: "include" });
if (!response.ok) {
throw new Error(`BIM conversion status failed: HTTP ${response.status}`);
}
const payload = (await response.json()) as TBeamConversionStatus;
return {
...payload,
artifactUrl: toBeamAbsoluteUrl(payload.artifactSrc, payload.updatedAt),
metadataUrl: toBeamAbsoluteUrl(payload.metadataSrc),
};
};
export const fetchBeamModelVersions = async (
beamViewer: TBeamViewerAttachment
): Promise<TBeamVersionHistoryResponse> => {
const versionsUrl = new URL("/api/uploads/versions", `${getBeamApiBaseUrl()}/`);
if (beamViewer.projectId && beamViewer.assetId) {
versionsUrl.searchParams.set("projectId", beamViewer.projectId);
versionsUrl.searchParams.set("assetId", beamViewer.assetId);
} else {
versionsUrl.searchParams.set("src", toBeamRelativeUploadSrc(beamViewer.src));
}
const response = await fetch(versionsUrl.toString(), { credentials: "include" });
if (!response.ok) {
throw new Error(`BIM version history failed: HTTP ${response.status}`);
}
return (await response.json()) as TBeamVersionHistoryResponse;
};
export const uploadBeamModelFile = (
file: File,
options: {
assetId?: string;
issueId?: string;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
projectId?: string;
uploadedBy?: string;
version?: number;
workspaceSlug?: string;
} = {}
): Promise<TBeamViewerAttachment> =>
new Promise((resolve, reject) => {
const type = getBeamModelTypeFromName(file.name);
if (!type) {
reject(new Error("Формат модели не поддерживается BIM Viewer"));
return;
}
const directViewerType = getBeamDirectModelTypeFromName(file.name);
const apiBaseUrl = getBeamApiBaseUrl();
const uploadUrl = new URL("/api/uploads", `${apiBaseUrl}/`);
uploadUrl.searchParams.set("filename", file.name);
uploadUrl.searchParams.set(
"projectId",
getUploadGroupId([options.workspaceSlug, options.projectId, options.issueId])
);
if (options.assetId) uploadUrl.searchParams.set("assetId", options.assetId);
if (options.version) uploadUrl.searchParams.set("version", String(options.version));
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl.toString());
xhr.withCredentials = true;
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.addEventListener("progress", (event) => {
if (!event.lengthComputable || !options.onUploadProgress) return;
options.onUploadProgress({
loaded: event.loaded,
progress: event.loaded / event.total,
total: event.total,
} as AxiosProgressEvent);
});
xhr.addEventListener("error", () => reject(new Error("Не удалось загрузить модель в BIM Viewer")));
xhr.addEventListener("load", () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(xhr.responseText || `BIM Viewer upload failed: HTTP ${xhr.status}`));
return;
}
try {
const response = JSON.parse(xhr.responseText) as {
assetId?: string;
conversion?: TBeamViewerAttachment["conversion"];
originalFilename?: string;
projectId?: string;
sha256?: string;
src?: string;
uploadedAt?: string;
version?: number;
versionId?: string;
};
if (!response.src) {
reject(new Error("BIM Viewer не вернул путь к загруженной модели"));
return;
}
const downloadUrl = new URL(response.src.startsWith("/") ? response.src : `/${response.src}`, `${apiBaseUrl}/`);
const baseAttachment: TBeamViewerAttachment = {
assetId: response.assetId ?? options.assetId,
backend: "beam-viewer-local",
downloadUrl: downloadUrl.toString(),
originalFilename: response.originalFilename || file.name,
previewAvailable: !!directViewerType,
projectId: response.projectId,
sha256: response.sha256,
src: downloadUrl.toString(),
type,
uploadedBy: options.uploadedBy,
uploadedAt: response.uploadedAt || new Date().toISOString(),
version: response.version ?? options.version ?? 1,
versionId: response.versionId,
};
if (!directViewerType) {
const nextAttachment: TBeamViewerAttachment = {
...baseAttachment,
conversion: {
...response.conversion,
componentTreeRequired: true,
message:
response.conversion?.message ??
"Оригинальный STEP загружен. Просмотр появится после подготовки модели и дерева компонентов.",
sourceFormat: type,
status: response.conversion?.status ?? "conversion_required",
targetFormat: response.conversion?.targetFormat ?? "xkt",
},
};
resolve({
...nextAttachment,
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
});
return;
}
const nextAttachment: TBeamViewerAttachment = {
...baseAttachment,
viewerUrl: buildBeamViewerUrl({
name: file.name,
settingsSrc: downloadUrl.toString(),
src: downloadUrl.toString(),
type: directViewerType,
}),
};
resolve({
...nextAttachment,
versions: [createBeamModelVersionRecord(nextAttachment, { size: file.size })],
});
} catch (_error) {
reject(new Error("BIM Viewer вернул некорректный ответ"));
}
});
xhr.send(file);
});
const deleteBeamStorageResource = async (
endpoint: "/api/uploads/asset" | "/api/uploads/version",
payload: unknown
): Promise<void> => {
const response = await fetch(new URL(endpoint, `${getBeamApiBaseUrl()}/`).toString(), {
method: "DELETE",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error((await response.text()) || `BIM storage delete failed: HTTP ${response.status}`);
}
};
export const deleteBeamModelAsset = async (beamViewer: TBeamViewerAttachment): Promise<void> =>
deleteBeamStorageResource("/api/uploads/asset", beamViewer);
export const deleteBeamModelVersion = async (version: TBeamModelVersionRecord): Promise<void> =>
deleteBeamStorageResource("/api/uploads/version", version);
+79 -1
View File
@@ -2403,7 +2403,8 @@
var(--nodedc-list-property-icon-size)
var(--nodedc-list-property-icon-size)
var(--nodedc-list-property-icon-size);
grid-auto-flow: row;
grid-template-rows: var(--nodedc-list-property-chip-height);
grid-auto-flow: column;
justify-content: start;
align-items: center;
column-gap: 0.28rem !important;
@@ -2419,6 +2420,7 @@
height: var(--nodedc-list-property-chip-height) !important;
width: 100%;
min-width: 0;
grid-row: 1;
align-items: center;
justify-content: center;
}
@@ -2735,6 +2737,7 @@
.nodedc-list-property-attachments,
.nodedc-list-property-links {
grid-column: auto;
grid-row: auto;
width: auto;
}
}
@@ -7520,6 +7523,81 @@
color: var(--text-color-primary) !important;
}
.nodedc-attachment-markdown-preview {
color: var(--text-color-secondary);
line-height: 1.7;
}
.nodedc-attachment-markdown-preview > * + * {
margin-top: 1rem;
}
.nodedc-attachment-markdown-preview h1,
.nodedc-attachment-markdown-preview h2,
.nodedc-attachment-markdown-preview h3,
.nodedc-attachment-markdown-preview h4 {
color: var(--text-color-primary);
line-height: 1.3;
}
.nodedc-attachment-markdown-preview h1 {
font-size: 1.5rem;
}
.nodedc-attachment-markdown-preview h2 {
font-size: 1.25rem;
}
.nodedc-attachment-markdown-preview h3,
.nodedc-attachment-markdown-preview h4 {
font-size: 1rem;
}
.nodedc-attachment-markdown-preview blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(var(--nodedc-accent-rgb), 0.48);
color: var(--text-color-tertiary);
}
.nodedc-attachment-markdown-preview code {
padding: 0.12rem 0.35rem;
border-radius: 0.35rem;
background: rgba(255, 255, 255, 0.07);
color: var(--text-color-primary);
font-family: var(--font-code);
}
.nodedc-attachment-markdown-preview pre {
overflow-x: auto;
padding: 1rem;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 0.85rem;
background: rgba(0, 0, 0, 0.24);
}
.nodedc-attachment-markdown-preview pre code {
padding: 0;
background: transparent;
}
.nodedc-attachment-markdown-preview table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
}
.nodedc-attachment-markdown-preview th,
.nodedc-attachment-markdown-preview td {
padding: 0.55rem 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.nodedc-attachment-markdown-preview a {
color: rgb(var(--nodedc-accent-rgb));
}
.nodedc-attachments-panel[data-view-mode="list"] {
padding-bottom: 0.75rem;
}
+38 -5
View File
@@ -10,6 +10,30 @@ import { fileTypeFromBuffer } from "file-type";
import type { TFileMetaDataLite, TFileSignedURLResponse } from "@plane/types";
import { DANGEROUS_EXTENSIONS } from "@plane/constants";
const TEXT_MIME_TYPES_BY_EXTENSION: Readonly<Record<string, string>> = {
cfg: "text/plain",
conf: "text/plain",
csv: "text/csv",
ini: "text/plain",
json: "application/json",
jsonl: "application/x-ndjson",
log: "text/plain",
markdown: "text/markdown",
md: "text/markdown",
mdown: "text/markdown",
mkd: "text/markdown",
mkdn: "text/markdown",
ndjson: "application/x-ndjson",
toml: "application/toml",
tsv: "text/tab-separated-values",
txt: "text/plain",
xml: "application/xml",
yaml: "application/yaml",
yml: "application/yaml",
};
const getFileExtension = (filename: string): string => filename.split(".").pop()?.trim().toLowerCase() ?? "";
/**
* @description Filename validation - checks for double extensions and dangerous patterns
* @param {string} filename
@@ -82,8 +106,10 @@ const detectMimeTypeFromSignature = async (file: File): Promise<string> => {
};
/**
* @description Validate and detect the MIME type of a file using signature detection
* Also performs basic security checks on filename
* @description Validate and detect the MIME type of a file.
* Binary signatures take precedence. Text formats are resolved from a conservative
* extension map because they do not have a binary signature. Browser MIME metadata
* and application/octet-stream provide compatibility for other safe attachments.
* @param {File} file
* @returns {Promise<string>} validated and detected MIME type
*/
@@ -91,7 +117,7 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
// Basic filename validation
const filenameError = validateFilename(file.name);
if (filenameError) {
console.warn(`File validation warning: ${filenameError}`);
throw new Error(filenameError);
}
try {
@@ -103,8 +129,15 @@ const validateAndDetectFileType = async (file: File): Promise<string> => {
console.warn("Error detecting file type from signature:", _error);
}
// fallback for unknown files
return "";
const extensionMimeType = TEXT_MIME_TYPES_BY_EXTENSION[getFileExtension(file.name)];
if (extensionMimeType) return extensionMimeType;
const browserMimeType = file.type.split(";", 1)[0]?.trim().toLowerCase();
if (browserMimeType) return browserMimeType;
// Preserve generic attachment support when neither the file signature nor the
// browser can identify a safe filename. The API still enforces its MIME allowlist.
return "application/octet-stream";
};
/**
@@ -142,7 +142,7 @@ export function MultiSelectDropdown(props: IMultiSelectDropdown) {
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
<div
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
ref={setPopperElement}
@@ -141,7 +141,7 @@ export function Dropdown(props: ISingleSelectDropdown) {
{isOpen &&
createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<Combobox.Options data-prevent-outside-click className="nodedc-external-popup-anchor fixed z-[760]" static>
<div
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
ref={setPopperElement}