Implement unified API standard across backend viewsets and serializers, enhancing error handling and response formatting. Update AccountModelViewSet to standardize CRUD operations with success and error responses. Refactor various viewsets to inherit from AccountModelViewSet, ensuring compliance with the new standard. Improve frontend components to handle API responses consistently and update configuration for better user experience.
This commit is contained in:
@@ -1,9 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Base ViewSet with account filtering support
|
Base ViewSet with account filtering support
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.exceptions import ValidationError as DRFValidationError
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from .response import success_response, error_response
|
||||||
|
|
||||||
|
|
||||||
class AccountModelViewSet(viewsets.ModelViewSet):
|
class AccountModelViewSet(viewsets.ModelViewSet):
|
||||||
@@ -75,6 +78,123 @@ class AccountModelViewSet(viewsets.ModelViewSet):
|
|||||||
context['account'] = account
|
context['account'] = account
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Override retrieve to return unified format
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return success_response(data=serializer.data, request=request)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Override create to return unified format
|
||||||
|
"""
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
try:
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_create(serializer)
|
||||||
|
headers = self.get_success_headers(serializer.data)
|
||||||
|
return success_response(
|
||||||
|
data=serializer.data,
|
||||||
|
message='Created successfully',
|
||||||
|
request=request,
|
||||||
|
status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
except DRFValidationError as e:
|
||||||
|
return error_response(
|
||||||
|
error='Validation error',
|
||||||
|
errors=e.detail if hasattr(e, 'detail') else str(e),
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error in create method: {str(e)}", exc_info=True)
|
||||||
|
# Check if it's a validation-related error
|
||||||
|
if 'required' in str(e).lower() or 'invalid' in str(e).lower() or 'validation' in str(e).lower():
|
||||||
|
return error_response(
|
||||||
|
error='Validation error',
|
||||||
|
errors=str(e),
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
# For other errors, return 500
|
||||||
|
return error_response(
|
||||||
|
error=f'Internal server error: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Override update to return unified format
|
||||||
|
"""
|
||||||
|
partial = kwargs.pop('partial', False)
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance, data=request.data, partial=partial)
|
||||||
|
try:
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
self.perform_update(serializer)
|
||||||
|
return success_response(
|
||||||
|
data=serializer.data,
|
||||||
|
message='Updated successfully',
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except DRFValidationError as e:
|
||||||
|
return error_response(
|
||||||
|
error='Validation error',
|
||||||
|
errors=e.detail if hasattr(e, 'detail') else str(e),
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error in create method: {str(e)}", exc_info=True)
|
||||||
|
# Check if it's a validation-related error
|
||||||
|
if 'required' in str(e).lower() or 'invalid' in str(e).lower() or 'validation' in str(e).lower():
|
||||||
|
return error_response(
|
||||||
|
error='Validation error',
|
||||||
|
errors=str(e),
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
# For other errors, return 500
|
||||||
|
return error_response(
|
||||||
|
error=f'Internal server error: {str(e)}',
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Override destroy to return unified format
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
instance = self.get_object()
|
||||||
|
self.perform_destroy(instance)
|
||||||
|
return success_response(
|
||||||
|
data=None,
|
||||||
|
message='Deleted successfully',
|
||||||
|
request=request,
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SiteSectorModelViewSet(AccountModelViewSet):
|
class SiteSectorModelViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class GroupsViewSet(viewsets.ViewSet):
|
|||||||
# 2. USERS - Manage global user records and credentials
|
# 2. USERS - Manage global user records and credentials
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class UsersViewSet(viewsets.ModelViewSet):
|
class UsersViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing global user records and credentials.
|
ViewSet for managing global user records and credentials.
|
||||||
Users are global, but belong to accounts.
|
Users are global, but belong to accounts.
|
||||||
@@ -246,13 +246,17 @@ class UsersViewSet(viewsets.ModelViewSet):
|
|||||||
# 3. ACCOUNTS - Register each unique organization/user space
|
# 3. ACCOUNTS - Register each unique organization/user space
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class AccountsViewSet(viewsets.ModelViewSet):
|
class AccountsViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing accounts (unique organization/user spaces).
|
ViewSet for managing accounts (unique organization/user spaces).
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = Account.objects.all()
|
queryset = Account.objects.all()
|
||||||
serializer_class = AccountSerializer
|
serializer_class = AccountSerializer
|
||||||
permission_classes = [IsOwnerOrAdmin]
|
permission_classes = [IsOwnerOrAdmin]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'auth'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return accounts based on access level."""
|
"""Return accounts based on access level."""
|
||||||
@@ -299,12 +303,16 @@ class AccountsViewSet(viewsets.ModelViewSet):
|
|||||||
# 4. SUBSCRIPTIONS - Control plan level, limits, and billing per account
|
# 4. SUBSCRIPTIONS - Control plan level, limits, and billing per account
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class SubscriptionsViewSet(viewsets.ModelViewSet):
|
class SubscriptionsViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing subscriptions (plan level, limits, billing per account).
|
ViewSet for managing subscriptions (plan level, limits, billing per account).
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = Subscription.objects.all()
|
queryset = Subscription.objects.all()
|
||||||
permission_classes = [IsOwnerOrAdmin]
|
permission_classes = [IsOwnerOrAdmin]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'auth'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return subscriptions based on access level."""
|
"""Return subscriptions based on access level."""
|
||||||
@@ -348,13 +356,17 @@ class SubscriptionsViewSet(viewsets.ModelViewSet):
|
|||||||
# 5. SITE USER ACCESS - Assign users access to specific sites within account
|
# 5. SITE USER ACCESS - Assign users access to specific sites within account
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class SiteUserAccessViewSet(viewsets.ModelViewSet):
|
class SiteUserAccessViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing Site-User access permissions.
|
ViewSet for managing Site-User access permissions.
|
||||||
Assign users access to specific sites within their account.
|
Assign users access to specific sites within their account.
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
serializer_class = SiteUserAccessSerializer
|
serializer_class = SiteUserAccessSerializer
|
||||||
permission_classes = [IsOwnerOrAdmin]
|
permission_classes = [IsOwnerOrAdmin]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'auth'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return access records for sites in user's account."""
|
"""Return access records for sites in user's account."""
|
||||||
@@ -383,10 +395,29 @@ class SiteUserAccessViewSet(viewsets.ModelViewSet):
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class PlanViewSet(viewsets.ReadOnlyModelViewSet):
|
class PlanViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""ViewSet for listing active subscription plans."""
|
"""
|
||||||
|
ViewSet for listing active subscription plans.
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
|
"""
|
||||||
queryset = Plan.objects.filter(is_active=True)
|
queryset = Plan.objects.filter(is_active=True)
|
||||||
serializer_class = PlanSerializer
|
serializer_class = PlanSerializer
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'auth'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""Override retrieve to return unified format"""
|
||||||
|
try:
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return success_response(data=serializer.data, request=request)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SiteViewSet(AccountModelViewSet):
|
class SiteViewSet(AccountModelViewSet):
|
||||||
@@ -662,10 +693,16 @@ class SectorViewSet(AccountModelViewSet):
|
|||||||
|
|
||||||
|
|
||||||
class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
|
class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""ViewSet for industry templates."""
|
"""
|
||||||
|
ViewSet for industry templates.
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
|
"""
|
||||||
queryset = Industry.objects.filter(is_active=True).prefetch_related('sectors')
|
queryset = Industry.objects.filter(is_active=True).prefetch_related('sectors')
|
||||||
serializer_class = IndustrySerializer
|
serializer_class = IndustrySerializer
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'auth'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def list(self, request):
|
def list(self, request):
|
||||||
"""Get all industries with their sectors."""
|
"""Get all industries with their sectors."""
|
||||||
@@ -676,12 +713,31 @@ class IndustryViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
request=request
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""Override retrieve to return unified format"""
|
||||||
|
try:
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return success_response(data=serializer.data, request=request)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""ViewSet for SeedKeyword - Global reference data (read-only for non-admins)."""
|
"""
|
||||||
|
ViewSet for SeedKeyword - Global reference data (read-only for non-admins).
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
|
"""
|
||||||
queryset = SeedKeyword.objects.filter(is_active=True).select_related('industry', 'sector')
|
queryset = SeedKeyword.objects.filter(is_active=True).select_related('industry', 'sector')
|
||||||
serializer_class = SeedKeywordSerializer
|
serializer_class = SeedKeywordSerializer
|
||||||
permission_classes = [permissions.AllowAny] # Read-only, allow any authenticated user
|
permission_classes = [permissions.AllowAny] # Read-only, allow any authenticated user
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'auth'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
|
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
|
||||||
search_fields = ['keyword']
|
search_fields = ['keyword']
|
||||||
@@ -689,6 +745,19 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
ordering = ['keyword']
|
ordering = ['keyword']
|
||||||
filterset_fields = ['industry', 'sector', 'intent', 'is_active']
|
filterset_fields = ['industry', 'sector', 'intent', 'is_active']
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
"""Override retrieve to return unified format"""
|
||||||
|
try:
|
||||||
|
instance = self.get_object()
|
||||||
|
serializer = self.get_serializer(instance)
|
||||||
|
return success_response(data=serializer.data, request=request)
|
||||||
|
except Exception as e:
|
||||||
|
return error_response(
|
||||||
|
error=str(e),
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
|
)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Filter by industry and sector if provided."""
|
"""Filter by industry and sector if provided."""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ class KeywordSerializer(serializers.ModelSerializer):
|
|||||||
intent = serializers.CharField(read_only=True) # From seed_keyword.intent
|
intent = serializers.CharField(read_only=True) # From seed_keyword.intent
|
||||||
|
|
||||||
# SeedKeyword relationship
|
# SeedKeyword relationship
|
||||||
seed_keyword_id = serializers.IntegerField(write_only=True, required=True)
|
# Required for create, optional for update (can change seed_keyword or just update other fields)
|
||||||
|
seed_keyword_id = serializers.IntegerField(write_only=True, required=False)
|
||||||
seed_keyword = SeedKeywordSerializer(read_only=True)
|
seed_keyword = SeedKeywordSerializer(read_only=True)
|
||||||
|
|
||||||
# Overrides
|
# Overrides
|
||||||
@@ -50,9 +51,19 @@ class KeywordSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keyword', 'volume', 'difficulty', 'intent']
|
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keyword', 'volume', 'difficulty', 'intent']
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Validate that seed_keyword_id is provided for create operations"""
|
||||||
|
# For create operations, seed_keyword_id is required
|
||||||
|
if self.instance is None and 'seed_keyword_id' not in attrs:
|
||||||
|
raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'})
|
||||||
|
return attrs
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
"""Create Keywords instance with seed_keyword"""
|
"""Create Keywords instance with seed_keyword"""
|
||||||
seed_keyword_id = validated_data.pop('seed_keyword_id')
|
seed_keyword_id = validated_data.pop('seed_keyword_id', None)
|
||||||
|
if not seed_keyword_id:
|
||||||
|
raise serializers.ValidationError({'seed_keyword_id': 'This field is required when creating a keyword.'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id)
|
seed_keyword = SeedKeyword.objects.get(id=seed_keyword_id)
|
||||||
except SeedKeyword.DoesNotExist:
|
except SeedKeyword.DoesNotExist:
|
||||||
@@ -63,6 +74,7 @@ class KeywordSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
"""Update Keywords instance with seed_keyword"""
|
"""Update Keywords instance with seed_keyword"""
|
||||||
|
# seed_keyword_id is optional for updates - only update if provided
|
||||||
if 'seed_keyword_id' in validated_data:
|
if 'seed_keyword_id' in validated_data:
|
||||||
seed_keyword_id = validated_data.pop('seed_keyword_id')
|
seed_keyword_id = validated_data.pop('seed_keyword_id')
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
"""
|
"""
|
||||||
ViewSets for Settings Models
|
ViewSets for Settings Models
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
from rest_framework import viewsets, status, permissions
|
from rest_framework import viewsets, status, permissions
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from igny8_core.api.base import AccountModelViewSet
|
from igny8_core.api.base import AccountModelViewSet
|
||||||
|
from igny8_core.api.response import success_response, error_response
|
||||||
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
|
from igny8_core.api.authentication import JWTAuthentication, CSRFExemptSessionAuthentication
|
||||||
|
from igny8_core.api.pagination import CustomPageNumberPagination
|
||||||
|
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||||
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
from .settings_models import SystemSettings, AccountSettings, UserSettings, ModuleSettings, AISettings
|
||||||
from .settings_serializers import (
|
from .settings_serializers import (
|
||||||
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
SystemSettingsSerializer, AccountSettingsSerializer, UserSettingsSerializer,
|
||||||
@@ -14,14 +18,18 @@ from .settings_serializers import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SystemSettingsViewSet(viewsets.ModelViewSet):
|
class SystemSettingsViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing system-wide settings (admin only for write operations)
|
ViewSet for managing system-wide settings (admin only for write operations)
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = SystemSettings.objects.all()
|
queryset = SystemSettings.objects.all()
|
||||||
serializer_class = SystemSettingsSerializer
|
serializer_class = SystemSettingsSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated] # Require authentication
|
permission_classes = [permissions.IsAuthenticated] # Require authentication
|
||||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'system'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
"""Admin only for write operations, read for authenticated users"""
|
"""Admin only for write operations, read for authenticated users"""
|
||||||
@@ -43,23 +51,28 @@ class SystemSettingsViewSet(viewsets.ModelViewSet):
|
|||||||
try:
|
try:
|
||||||
setting = SystemSettings.objects.get(key=pk)
|
setting = SystemSettings.objects.get(key=pk)
|
||||||
except SystemSettings.DoesNotExist:
|
except SystemSettings.DoesNotExist:
|
||||||
return Response(
|
return error_response(
|
||||||
{'error': 'Setting not found'},
|
error='Setting not found',
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(setting)
|
serializer = self.get_serializer(setting)
|
||||||
return Response(serializer.data)
|
return success_response(data=serializer.data, request=request)
|
||||||
|
|
||||||
|
|
||||||
class AccountSettingsViewSet(AccountModelViewSet):
|
class AccountSettingsViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing account-level settings
|
ViewSet for managing account-level settings
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = AccountSettings.objects.all()
|
queryset = AccountSettings.objects.all()
|
||||||
serializer_class = AccountSettingsSerializer
|
serializer_class = AccountSettingsSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'system'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get settings for current account"""
|
"""Get settings for current account"""
|
||||||
@@ -76,13 +89,14 @@ class AccountSettingsViewSet(AccountModelViewSet):
|
|||||||
try:
|
try:
|
||||||
setting = queryset.get(key=pk)
|
setting = queryset.get(key=pk)
|
||||||
except AccountSettings.DoesNotExist:
|
except AccountSettings.DoesNotExist:
|
||||||
return Response(
|
return error_response(
|
||||||
{'error': 'Setting not found'},
|
error='Setting not found',
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(setting)
|
serializer = self.get_serializer(setting)
|
||||||
return Response(serializer.data)
|
return success_response(data=serializer.data, request=request)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set account automatically"""
|
"""Set account automatically"""
|
||||||
@@ -99,14 +113,18 @@ class AccountSettingsViewSet(AccountModelViewSet):
|
|||||||
serializer.save(account=account)
|
serializer.save(account=account)
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsViewSet(viewsets.ModelViewSet):
|
class UserSettingsViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing user-level settings
|
ViewSet for managing user-level settings
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = UserSettings.objects.all()
|
queryset = UserSettings.objects.all()
|
||||||
serializer_class = UserSettingsSerializer
|
serializer_class = UserSettingsSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'system'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get settings for current user and account"""
|
"""Get settings for current user and account"""
|
||||||
@@ -130,13 +148,14 @@ class UserSettingsViewSet(viewsets.ModelViewSet):
|
|||||||
try:
|
try:
|
||||||
setting = queryset.get(key=pk)
|
setting = queryset.get(key=pk)
|
||||||
except UserSettings.DoesNotExist:
|
except UserSettings.DoesNotExist:
|
||||||
return Response(
|
return error_response(
|
||||||
{'error': 'Setting not found'},
|
error='Setting not found',
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(setting)
|
serializer = self.get_serializer(setting)
|
||||||
return Response(serializer.data)
|
return success_response(data=serializer.data, request=request)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set user and account automatically"""
|
"""Set user and account automatically"""
|
||||||
@@ -155,11 +174,15 @@ class UserSettingsViewSet(viewsets.ModelViewSet):
|
|||||||
class ModuleSettingsViewSet(AccountModelViewSet):
|
class ModuleSettingsViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing module-specific settings
|
ViewSet for managing module-specific settings
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = ModuleSettings.objects.all()
|
queryset = ModuleSettings.objects.all()
|
||||||
serializer_class = ModuleSettingsSerializer
|
serializer_class = ModuleSettingsSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'system'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get settings for current account, optionally filtered by module"""
|
"""Get settings for current account, optionally filtered by module"""
|
||||||
@@ -174,7 +197,7 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
|||||||
"""Get all settings for a specific module"""
|
"""Get all settings for a specific module"""
|
||||||
queryset = self.get_queryset().filter(module_name=module_name)
|
queryset = self.get_queryset().filter(module_name=module_name)
|
||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return Response(serializer.data)
|
return success_response(data=serializer.data, request=request)
|
||||||
|
|
||||||
def retrieve(self, request, pk=None):
|
def retrieve(self, request, pk=None):
|
||||||
"""Get setting by key (pk can be key string)"""
|
"""Get setting by key (pk can be key string)"""
|
||||||
@@ -189,18 +212,20 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
|||||||
try:
|
try:
|
||||||
setting = queryset.get(module_name=module_name, key=pk)
|
setting = queryset.get(module_name=module_name, key=pk)
|
||||||
except ModuleSettings.DoesNotExist:
|
except ModuleSettings.DoesNotExist:
|
||||||
return Response(
|
return error_response(
|
||||||
{'error': 'Setting not found'},
|
error='Setting not found',
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return Response(
|
return error_response(
|
||||||
{'error': 'Setting not found'},
|
error='Setting not found',
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(setting)
|
serializer = self.get_serializer(setting)
|
||||||
return Response(serializer.data)
|
return success_response(data=serializer.data, request=request)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set account automatically"""
|
"""Set account automatically"""
|
||||||
@@ -220,11 +245,15 @@ class ModuleSettingsViewSet(AccountModelViewSet):
|
|||||||
class AISettingsViewSet(AccountModelViewSet):
|
class AISettingsViewSet(AccountModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing AI-specific settings
|
ViewSet for managing AI-specific settings
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = AISettings.objects.all()
|
queryset = AISettings.objects.all()
|
||||||
serializer_class = AISettingsSerializer
|
serializer_class = AISettingsSerializer
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
authentication_classes = [JWTAuthentication, CSRFExemptSessionAuthentication]
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'system'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Get AI settings for current account"""
|
"""Get AI settings for current account"""
|
||||||
@@ -241,13 +270,14 @@ class AISettingsViewSet(AccountModelViewSet):
|
|||||||
try:
|
try:
|
||||||
setting = queryset.get(integration_type=pk)
|
setting = queryset.get(integration_type=pk)
|
||||||
except AISettings.DoesNotExist:
|
except AISettings.DoesNotExist:
|
||||||
return Response(
|
return error_response(
|
||||||
{'error': 'AI Setting not found'},
|
error='AI Setting not found',
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
request=request
|
||||||
)
|
)
|
||||||
|
|
||||||
serializer = self.get_serializer(setting)
|
serializer = self.get_serializer(setting)
|
||||||
return Response(serializer.data)
|
return success_response(data=serializer.data, request=request)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set account automatically"""
|
"""Set account automatically"""
|
||||||
|
|||||||
@@ -375,9 +375,13 @@ class TasksViewSet(SiteSectorModelViewSet):
|
|||||||
class ImagesViewSet(SiteSectorModelViewSet):
|
class ImagesViewSet(SiteSectorModelViewSet):
|
||||||
"""
|
"""
|
||||||
ViewSet for managing content images
|
ViewSet for managing content images
|
||||||
|
Unified API Standard v1.0 compliant
|
||||||
"""
|
"""
|
||||||
queryset = Images.objects.all()
|
queryset = Images.objects.all()
|
||||||
serializer_class = ImagesSerializer
|
serializer_class = ImagesSerializer
|
||||||
|
pagination_class = CustomPageNumberPagination
|
||||||
|
throttle_scope = 'writer'
|
||||||
|
throttle_classes = [DebugScopedRateThrottle]
|
||||||
|
|
||||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||||
ordering_fields = ['created_at', 'position', 'id']
|
ordering_fields = ['created_at', 'position', 'id']
|
||||||
@@ -385,12 +389,37 @@ class ImagesViewSet(SiteSectorModelViewSet):
|
|||||||
filterset_fields = ['task_id', 'content_id', 'image_type', 'status']
|
filterset_fields = ['task_id', 'content_id', 'image_type', 'status']
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Override to automatically set account"""
|
"""Override to automatically set account, site, and sector"""
|
||||||
account = getattr(self.request, 'account', None)
|
from rest_framework.exceptions import ValidationError
|
||||||
if account:
|
|
||||||
serializer.save(account=account)
|
# Get site and sector from request (set by middleware) or user's active context
|
||||||
else:
|
site = getattr(self.request, 'site', None)
|
||||||
serializer.save()
|
sector = getattr(self.request, 'sector', None)
|
||||||
|
|
||||||
|
if not site:
|
||||||
|
# Fallback to user's active site if not set by middleware
|
||||||
|
user = getattr(self.request, 'user', None)
|
||||||
|
if user and user.is_authenticated and hasattr(user, 'active_site'):
|
||||||
|
site = user.active_site
|
||||||
|
|
||||||
|
if not sector and site:
|
||||||
|
# Fallback to default sector for the site if not set by middleware
|
||||||
|
from igny8_core.auth.models import Sector
|
||||||
|
sector = site.sectors.filter(is_default=True).first()
|
||||||
|
|
||||||
|
# Site and sector are required - raise ValidationError if not available
|
||||||
|
# Use dict format for ValidationError to ensure proper error structure
|
||||||
|
if not site:
|
||||||
|
raise ValidationError({"site": ["Site is required for image creation. Please select a site."]})
|
||||||
|
if not sector:
|
||||||
|
raise ValidationError({"sector": ["Sector is required for image creation. Please select a sector."]})
|
||||||
|
|
||||||
|
# Add site and sector to validated_data so base class can validate access
|
||||||
|
serializer.validated_data['site'] = site
|
||||||
|
serializer.validated_data['sector'] = sector
|
||||||
|
|
||||||
|
# Call parent to set account and validate access
|
||||||
|
super().perform_create(serializer)
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='file', url_name='image_file')
|
@action(detail=True, methods=['get'], url_path='file', url_name='image_file')
|
||||||
def serve_image_file(self, request, pk=None):
|
def serve_image_file(self, request, pk=None):
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
interface ComponentCardProps {
|
interface ComponentCardProps {
|
||||||
title: string;
|
title: string | React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string; // Additional custom classes for styling
|
className?: string; // Additional custom classes for styling
|
||||||
desc?: string; // Description text
|
desc?: string | React.ReactNode; // Description text
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComponentCard: React.FC<ComponentCardProps> = ({
|
const ComponentCard: React.FC<ComponentCardProps> = ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
import { API_BASE_URL } from "../../services/api";
|
import { API_BASE_URL } from "../../services/api";
|
||||||
import { useAuthStore } from "../../store/authStore";
|
import { useAuthStore } from "../../store/authStore";
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ const endpointGroups = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Planner Module",
|
name: "Planner Module",
|
||||||
abbreviation: "PL",
|
abbreviation: "PM",
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{ path: "/v1/planner/keywords/", method: "GET" },
|
{ path: "/v1/planner/keywords/", method: "GET" },
|
||||||
{ path: "/v1/planner/keywords/auto_cluster/", method: "POST" },
|
{ path: "/v1/planner/keywords/auto_cluster/", method: "POST" },
|
||||||
@@ -49,7 +50,7 @@ const endpointGroups = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Writer Module",
|
name: "Writer Module",
|
||||||
abbreviation: "WR",
|
abbreviation: "WM",
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{ path: "/v1/writer/tasks/", method: "GET" },
|
{ path: "/v1/writer/tasks/", method: "GET" },
|
||||||
{ path: "/v1/writer/tasks/auto_generate_content/", method: "POST" },
|
{ path: "/v1/writer/tasks/auto_generate_content/", method: "POST" },
|
||||||
@@ -60,6 +61,48 @@ const endpointGroups = [
|
|||||||
{ path: "/v1/writer/images/generate_images/", method: "POST" },
|
{ path: "/v1/writer/images/generate_images/", method: "POST" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "CRUD Operations - Planner",
|
||||||
|
abbreviation: "PC",
|
||||||
|
endpoints: [
|
||||||
|
{ path: "/v1/planner/keywords/", method: "GET" },
|
||||||
|
{ path: "/v1/planner/keywords/", method: "POST" },
|
||||||
|
{ path: "/v1/planner/keywords/1/", method: "GET" },
|
||||||
|
{ path: "/v1/planner/keywords/1/", method: "PUT" },
|
||||||
|
{ path: "/v1/planner/keywords/1/", method: "DELETE" },
|
||||||
|
{ path: "/v1/planner/clusters/", method: "GET" },
|
||||||
|
{ path: "/v1/planner/clusters/", method: "POST" },
|
||||||
|
{ path: "/v1/planner/clusters/1/", method: "GET" },
|
||||||
|
{ path: "/v1/planner/clusters/1/", method: "PUT" },
|
||||||
|
{ path: "/v1/planner/clusters/1/", method: "DELETE" },
|
||||||
|
{ path: "/v1/planner/ideas/", method: "GET" },
|
||||||
|
{ path: "/v1/planner/ideas/", method: "POST" },
|
||||||
|
{ path: "/v1/planner/ideas/1/", method: "GET" },
|
||||||
|
{ path: "/v1/planner/ideas/1/", method: "PUT" },
|
||||||
|
{ path: "/v1/planner/ideas/1/", method: "DELETE" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CRUD Operations - Writer",
|
||||||
|
abbreviation: "WC",
|
||||||
|
endpoints: [
|
||||||
|
{ path: "/v1/writer/tasks/", method: "GET" },
|
||||||
|
{ path: "/v1/writer/tasks/", method: "POST" },
|
||||||
|
{ path: "/v1/writer/tasks/1/", method: "GET" },
|
||||||
|
{ path: "/v1/writer/tasks/1/", method: "PUT" },
|
||||||
|
{ path: "/v1/writer/tasks/1/", method: "DELETE" },
|
||||||
|
{ path: "/v1/writer/content/", method: "GET" },
|
||||||
|
{ path: "/v1/writer/content/", method: "POST" },
|
||||||
|
{ path: "/v1/writer/content/1/", method: "GET" },
|
||||||
|
{ path: "/v1/writer/content/1/", method: "PUT" },
|
||||||
|
{ path: "/v1/writer/content/1/", method: "DELETE" },
|
||||||
|
{ path: "/v1/writer/images/", method: "GET" },
|
||||||
|
{ path: "/v1/writer/images/", method: "POST" },
|
||||||
|
{ path: "/v1/writer/images/1/", method: "GET" },
|
||||||
|
{ path: "/v1/writer/images/1/", method: "PUT" },
|
||||||
|
{ path: "/v1/writer/images/1/", method: "DELETE" },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "System & Billing",
|
name: "System & Billing",
|
||||||
abbreviation: "SY",
|
abbreviation: "SY",
|
||||||
@@ -67,7 +110,7 @@ const endpointGroups = [
|
|||||||
{ path: "/v1/system/prompts/", method: "GET" },
|
{ path: "/v1/system/prompts/", method: "GET" },
|
||||||
{ path: "/v1/system/author-profiles/", method: "GET" },
|
{ path: "/v1/system/author-profiles/", method: "GET" },
|
||||||
{ path: "/v1/system/strategies/", method: "GET" },
|
{ path: "/v1/system/strategies/", method: "GET" },
|
||||||
{ path: "/v1/system/settings/integrations/1/test/", method: "POST" },
|
{ path: "/v1/system/settings/integrations/openai/test/", method: "POST" },
|
||||||
{ path: "/v1/system/settings/account/", method: "GET" },
|
{ path: "/v1/system/settings/account/", method: "GET" },
|
||||||
{ path: "/v1/billing/credits/balance/balance/", method: "GET" },
|
{ path: "/v1/billing/credits/balance/balance/", method: "GET" },
|
||||||
{ path: "/v1/billing/credits/usage/", method: "GET" },
|
{ path: "/v1/billing/credits/usage/", method: "GET" },
|
||||||
@@ -79,6 +122,7 @@ const endpointGroups = [
|
|||||||
|
|
||||||
export default function ApiStatusIndicator() {
|
export default function ApiStatusIndicator() {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
const [groupStatuses, setGroupStatuses] = useState<GroupStatus[]>([]);
|
const [groupStatuses, setGroupStatuses] = useState<GroupStatus[]>([]);
|
||||||
const [isChecking, setIsChecking] = useState(false);
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
const intervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const intervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -86,8 +130,11 @@ export default function ApiStatusIndicator() {
|
|||||||
// Only show and run for aws-admin accounts
|
// Only show and run for aws-admin accounts
|
||||||
const isAwsAdmin = user?.account?.slug === 'aws-admin';
|
const isAwsAdmin = user?.account?.slug === 'aws-admin';
|
||||||
|
|
||||||
// Return null if not aws-admin account
|
// Only run API checks on API monitor page to avoid console errors on other pages
|
||||||
if (!isAwsAdmin) {
|
const isApiMonitorPage = location.pathname === '/settings/api-monitor';
|
||||||
|
|
||||||
|
// Return null if not aws-admin account or not on API monitor page
|
||||||
|
if (!isAwsAdmin || !isApiMonitorPage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,8 +188,17 @@ export default function ApiStatusIndicator() {
|
|||||||
body = { username: 'test', password: 'test' };
|
body = { username: 'test', password: 'test' };
|
||||||
} else if (path.includes('/register/')) {
|
} else if (path.includes('/register/')) {
|
||||||
body = { username: 'test', email: 'test@test.com', password: 'test' };
|
body = { username: 'test', email: 'test@test.com', password: 'test' };
|
||||||
|
} else if (path.includes('/bulk_delete/')) {
|
||||||
|
body = { ids: [] }; // Empty array to trigger validation error
|
||||||
|
} else if (path.includes('/bulk_update/')) {
|
||||||
|
body = { ids: [] }; // Empty array to trigger validation error
|
||||||
}
|
}
|
||||||
fetchOptions.body = JSON.stringify(body);
|
fetchOptions.body = JSON.stringify(body);
|
||||||
|
} else if (method === 'PUT' || method === 'DELETE') {
|
||||||
|
// For PUT/DELETE, we need to send a body for PUT or handle DELETE
|
||||||
|
if (method === 'PUT') {
|
||||||
|
fetchOptions.body = JSON.stringify({}); // Empty object to trigger validation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress console errors for expected 400 responses (validation errors from test data)
|
// Suppress console errors for expected 400 responses (validation errors from test data)
|
||||||
@@ -154,11 +210,13 @@ export default function ApiStatusIndicator() {
|
|||||||
path.includes('/test/')
|
path.includes('/test/')
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions);
|
// Use a silent fetch that won't log to console for expected errors
|
||||||
|
let response: Response;
|
||||||
// Suppress console errors for expected 400 responses
|
try {
|
||||||
if (!isExpected400 || response.status !== 400) {
|
response = await fetch(`${API_BASE_URL}${path}`, fetchOptions);
|
||||||
// Only log if it's not an expected 400
|
} catch (fetchError) {
|
||||||
|
// Network errors are real errors
|
||||||
|
return 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actualMethod === 'OPTIONS') {
|
if (actualMethod === 'OPTIONS') {
|
||||||
@@ -176,24 +234,31 @@ export default function ApiStatusIndicator() {
|
|||||||
} else if (response.status === 401 || response.status === 403) {
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
return 'warning';
|
return 'warning';
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
return 'error';
|
// For GET requests to specific resource IDs (e.g., /v1/planner/keywords/1/),
|
||||||
|
// 404 is expected and healthy (resource doesn't exist, but endpoint works correctly)
|
||||||
|
// For other GET requests (like list endpoints), 404 means endpoint doesn't exist
|
||||||
|
const isResourceByIdRequest = /\/\d+\/?$/.test(path); // Path ends with /number/ or /number
|
||||||
|
if (isResourceByIdRequest) {
|
||||||
|
return 'healthy'; // GET to specific ID returning 404 is healthy (endpoint exists, resource doesn't)
|
||||||
|
}
|
||||||
|
return 'error'; // Endpoint doesn't exist
|
||||||
} else if (response.status >= 500) {
|
} else if (response.status >= 500) {
|
||||||
return 'error';
|
return 'error';
|
||||||
}
|
}
|
||||||
return 'warning';
|
return 'warning';
|
||||||
} else if (method === 'POST') {
|
} else if (method === 'POST') {
|
||||||
// Suppress console errors for expected 400 responses (validation errors from test data)
|
// Suppress console errors for expected 400 responses (validation errors from test data)
|
||||||
|
// CRUD POST endpoints (like /v1/planner/keywords/, /v1/writer/tasks/) return 400 for empty/invalid test data
|
||||||
const isExpected400 = path.includes('/login/') ||
|
const isExpected400 = path.includes('/login/') ||
|
||||||
path.includes('/register/') ||
|
path.includes('/register/') ||
|
||||||
path.includes('/bulk_') ||
|
path.includes('/bulk_') ||
|
||||||
path.includes('/test/');
|
path.includes('/test/') ||
|
||||||
|
// CRUD CREATE endpoints - POST to list endpoints (no ID in path, ends with / or exact match)
|
||||||
|
/\/v1\/(planner|writer)\/(keywords|clusters|ideas|tasks|content|images)\/?$/.test(path);
|
||||||
|
|
||||||
if (response.status === 400) {
|
if (response.status === 400) {
|
||||||
// 400 is expected for test requests - endpoint is working
|
// 400 is expected for test requests - endpoint is working
|
||||||
if (!isExpected400) {
|
// Don't log warnings for expected 400s - they're normal validation errors
|
||||||
// Only log if it's unexpected
|
|
||||||
console.warn(`[ApiStatusIndicator] ${method} ${path}: 400 (unexpected)`);
|
|
||||||
}
|
|
||||||
return 'healthy';
|
return 'healthy';
|
||||||
} else if (response.status >= 200 && response.status < 300) {
|
} else if (response.status >= 200 && response.status < 300) {
|
||||||
return 'healthy';
|
return 'healthy';
|
||||||
|
|||||||
@@ -515,7 +515,7 @@ export const createKeywordsPageConfig = (
|
|||||||
label: 'Seed Keyword',
|
label: 'Seed Keyword',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
placeholder: 'Select a seed keyword',
|
placeholder: 'Select a seed keyword',
|
||||||
value: handlers.formData.seed_keyword_id?.toString() || '',
|
value: (handlers.formData.seed_keyword_id && handlers.formData.seed_keyword_id > 0) ? handlers.formData.seed_keyword_id.toString() : '',
|
||||||
onChange: (value: any) =>
|
onChange: (value: any) =>
|
||||||
handlers.setFormData({ ...handlers.formData, seed_keyword_id: value ? parseInt(value) : 0 }),
|
handlers.setFormData({ ...handlers.formData, seed_keyword_id: value ? parseInt(value) : 0 }),
|
||||||
required: true,
|
required: true,
|
||||||
|
|||||||
@@ -874,6 +874,7 @@ export default function Keywords() {
|
|||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<FormModal
|
<FormModal
|
||||||
|
key={`keyword-form-${isEditMode ? editingKeyword?.id : 'new'}-${formData.seed_keyword_id}-${formData.status}`}
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
|
|||||||
@@ -299,7 +299,27 @@ export default function ApiMonitor() {
|
|||||||
fetchOptions.body = JSON.stringify({});
|
fetchOptions.body = JSON.stringify({});
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${path}`, fetchOptions);
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`${API_BASE_URL}${path}`, fetchOptions);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Network error or fetch failed
|
||||||
|
const responseTime = Date.now() - startTime;
|
||||||
|
setEndpointStatuses(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: {
|
||||||
|
endpoint: path,
|
||||||
|
method,
|
||||||
|
status: 'error',
|
||||||
|
responseTime,
|
||||||
|
error: error.message || 'Network error',
|
||||||
|
apiStatus: 'error',
|
||||||
|
dataStatus: 'error',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
// Determine status based on response
|
// Determine status based on response
|
||||||
@@ -349,38 +369,55 @@ export default function ApiMonitor() {
|
|||||||
if (responseData.success === false) {
|
if (responseData.success === false) {
|
||||||
status = 'error'; // API returned an error in unified format
|
status = 'error'; // API returned an error in unified format
|
||||||
} else if (responseData.success === true) {
|
} else if (responseData.success === true) {
|
||||||
// Check if data is empty for endpoints that should return data
|
// Check for paginated response format (success: true, count: X, results: [...])
|
||||||
// These endpoints should have data: {count: X, results: [...]} or data: {...}
|
// or single object response format (success: true, data: {...})
|
||||||
const shouldHaveData =
|
const isPaginatedResponse = 'results' in responseData && 'count' in responseData;
|
||||||
path.includes('/content_images/') ||
|
const isSingleObjectResponse = 'data' in responseData;
|
||||||
path.includes('/prompts/by_type/') ||
|
|
||||||
path.includes('/usage/limits/') ||
|
|
||||||
path.includes('/prompts/') && !path.includes('/save/');
|
|
||||||
|
|
||||||
if (shouldHaveData) {
|
if (isPaginatedResponse) {
|
||||||
// Check if data field exists and has content
|
// Paginated response - check results at top level
|
||||||
if (responseData.data === null || responseData.data === undefined) {
|
if (!Array.isArray(responseData.results)) {
|
||||||
status = 'warning'; // Missing data field
|
status = 'warning'; // Missing or invalid results array
|
||||||
} else if (Array.isArray(responseData.data) && responseData.data.length === 0) {
|
} else if (responseData.results.length === 0 && responseData.count === 0) {
|
||||||
// Empty array might be OK for some endpoints, but check if results should exist
|
// Empty results with count 0 is OK for list endpoints
|
||||||
|
// Only warn for critical endpoints that should have data
|
||||||
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
||||||
// These endpoints should return data, empty might indicate a problem
|
status = 'warning'; // No data available - might indicate configuration issue
|
||||||
status = 'warning'; // Empty data - might indicate configuration issue
|
|
||||||
}
|
}
|
||||||
} else if (typeof responseData.data === 'object' && responseData.data !== null) {
|
}
|
||||||
// Check if it's a paginated response with empty results
|
} else if (isSingleObjectResponse) {
|
||||||
if (responseData.data.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) {
|
// Single object response - check data field
|
||||||
// Empty results might be OK, but for critical endpoints it's a warning
|
const shouldHaveData =
|
||||||
|
path.includes('/content_images/') ||
|
||||||
|
path.includes('/prompts/by_type/') ||
|
||||||
|
path.includes('/usage/limits/');
|
||||||
|
|
||||||
|
if (shouldHaveData) {
|
||||||
|
if (responseData.data === null || responseData.data === undefined) {
|
||||||
|
status = 'warning'; // Missing data field
|
||||||
|
} else if (Array.isArray(responseData.data) && responseData.data.length === 0) {
|
||||||
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
||||||
status = 'warning'; // Empty results - might indicate data issue
|
status = 'warning'; // Empty data - might indicate configuration issue
|
||||||
}
|
}
|
||||||
} else if (responseData.data.count !== undefined && responseData.data.count === 0) {
|
} else if (typeof responseData.data === 'object' && responseData.data !== null) {
|
||||||
// Paginated response with count: 0
|
// Check if it's a nested paginated response
|
||||||
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
if (responseData.data.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) {
|
||||||
status = 'warning'; // No data available - might indicate configuration issue
|
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
||||||
|
status = 'warning'; // Empty results - might indicate data issue
|
||||||
|
}
|
||||||
|
} else if (responseData.data.count !== undefined && responseData.data.count === 0) {
|
||||||
|
if (path.includes('/content_images/') || path.includes('/prompts/by_type/')) {
|
||||||
|
status = 'warning'; // No data available - might indicate configuration issue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!isPaginatedResponse && !isSingleObjectResponse) {
|
||||||
|
// Response has success: true but no data or results
|
||||||
|
// For paginated list endpoints, this is a problem
|
||||||
|
if (path.includes('/prompts/') && !path.includes('/save/') && !path.includes('/by_type/')) {
|
||||||
|
status = 'warning'; // Paginated endpoint missing results field
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +431,15 @@ export default function ApiMonitor() {
|
|||||||
} else if (response.status === 401 || response.status === 403) {
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
status = 'warning'; // Endpoint exists, needs authentication
|
status = 'warning'; // Endpoint exists, needs authentication
|
||||||
} else if (response.status === 404) {
|
} else if (response.status === 404) {
|
||||||
status = 'error'; // Endpoint doesn't exist
|
// For GET requests to specific resource IDs (e.g., /v1/planner/keywords/1/),
|
||||||
|
// 404 is expected and healthy (resource doesn't exist, but endpoint works correctly)
|
||||||
|
// For other GET requests (like list endpoints), 404 means endpoint doesn't exist
|
||||||
|
const isResourceByIdRequest = /\/\d+\/?$/.test(path); // Path ends with /number/ or /number
|
||||||
|
if (method === 'GET' && isResourceByIdRequest) {
|
||||||
|
status = 'healthy'; // GET to specific ID returning 404 is healthy (endpoint exists, resource doesn't)
|
||||||
|
} else {
|
||||||
|
status = 'error'; // Endpoint doesn't exist
|
||||||
|
}
|
||||||
} else if (response.status >= 500) {
|
} else if (response.status >= 500) {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
} else {
|
} else {
|
||||||
@@ -510,18 +555,42 @@ export default function ApiMonitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log warnings/errors for issues detected in response content
|
// Log warnings/errors for issues detected in response content
|
||||||
if (status === 'warning' || status === 'error') {
|
// Skip logging for expected 400 responses on POST (validation errors are expected)
|
||||||
|
const isExpected400Post = method === 'POST' && response.status === 400;
|
||||||
|
if ((status === 'warning' || status === 'error') && !isExpected400Post) {
|
||||||
if (responseData) {
|
if (responseData) {
|
||||||
if (responseData.success === false) {
|
if (responseData.success === false) {
|
||||||
console.warn(`[API Monitor] ${method} ${path}: Unified format error - ${responseData.error || 'Unknown error'}`);
|
console.warn(`[API Monitor] ${method} ${path}: Unified format error - ${responseData.error || 'Unknown error'}`);
|
||||||
} else if (responseData.data === null || responseData.data === undefined) {
|
} else {
|
||||||
console.warn(`[API Monitor] ${method} ${path}: Missing data field in response`);
|
// Check for paginated response format
|
||||||
} else if (Array.isArray(responseData.data) && responseData.data.length === 0) {
|
const isPaginated = 'results' in responseData && 'count' in responseData;
|
||||||
console.warn(`[API Monitor] ${method} ${path}: Empty data array returned`);
|
const isSingleObject = 'data' in responseData;
|
||||||
} else if (responseData.data?.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) {
|
|
||||||
console.warn(`[API Monitor] ${method} ${path}: Empty results array returned`);
|
if (isPaginated) {
|
||||||
} else if (responseData.data?.count === 0) {
|
// Paginated response - check results at top level
|
||||||
console.warn(`[API Monitor] ${method} ${path}: No data available (count: 0)`);
|
if (!Array.isArray(responseData.results)) {
|
||||||
|
console.warn(`[API Monitor] ${method} ${path}: Missing or invalid results array in paginated response`);
|
||||||
|
} else if (responseData.results.length === 0 && responseData.count === 0 &&
|
||||||
|
(path.includes('/prompts/') && !path.includes('/save/') && !path.includes('/by_type/'))) {
|
||||||
|
console.warn(`[API Monitor] ${method} ${path}: Empty paginated response (count: 0, results: [])`);
|
||||||
|
}
|
||||||
|
} else if (isSingleObject) {
|
||||||
|
// Single object response - check data field
|
||||||
|
if (responseData.data === null || responseData.data === undefined) {
|
||||||
|
console.warn(`[API Monitor] ${method} ${path}: Missing data field in response`);
|
||||||
|
} else if (Array.isArray(responseData.data) && responseData.data.length === 0) {
|
||||||
|
console.warn(`[API Monitor] ${method} ${path}: Empty data array returned`);
|
||||||
|
} else if (responseData.data?.results && Array.isArray(responseData.data.results) && responseData.data.results.length === 0) {
|
||||||
|
console.warn(`[API Monitor] ${method} ${path}: Empty results array returned`);
|
||||||
|
} else if (responseData.data?.count === 0) {
|
||||||
|
console.warn(`[API Monitor] ${method} ${path}: No data available (count: 0)`);
|
||||||
|
}
|
||||||
|
} else if (responseData.success === true && !isPaginated && !isSingleObject) {
|
||||||
|
// Response has success: true but no data or results
|
||||||
|
if (path.includes('/prompts/') && !path.includes('/save/') && !path.includes('/by_type/')) {
|
||||||
|
console.warn(`[API Monitor] ${method} ${path}: Paginated endpoint missing results field`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,13 +598,16 @@ export default function ApiMonitor() {
|
|||||||
// Suppress console errors for expected monitoring responses
|
// Suppress console errors for expected monitoring responses
|
||||||
// Only log real errors (5xx, network errors, or unexpected 4xx for GET endpoints)
|
// Only log real errors (5xx, network errors, or unexpected 4xx for GET endpoints)
|
||||||
// Don't log expected 400s for POST endpoints (they indicate validation is working)
|
// Don't log expected 400s for POST endpoints (they indicate validation is working)
|
||||||
|
// Don't log expected 404s for GET requests to specific resource IDs (they indicate endpoint works correctly)
|
||||||
|
const isResourceByIdRequest = /\/\d+\/?$/.test(path); // Path ends with /number/ or /number
|
||||||
const isExpectedResponse =
|
const isExpectedResponse =
|
||||||
(method === 'POST' && response.status === 400) || // Expected validation error
|
(method === 'POST' && response.status === 400) || // Expected validation error
|
||||||
(actualMethod === 'OPTIONS' && response.status === 200) || // Expected OPTIONS success
|
(actualMethod === 'OPTIONS' && response.status === 200) || // Expected OPTIONS success
|
||||||
(method === 'GET' && response.status >= 200 && response.status < 300 && status === 'healthy'); // Expected GET success with valid data
|
(method === 'GET' && response.status >= 200 && response.status < 300 && status === 'healthy') || // Expected GET success with valid data
|
||||||
|
(method === 'GET' && response.status === 404 && isResourceByIdRequest); // Expected 404 for GET to non-existent resource ID
|
||||||
|
|
||||||
if (!isExpectedResponse && (response.status >= 500 ||
|
if (!isExpectedResponse && (response.status >= 500 ||
|
||||||
(method === 'GET' && response.status === 404) ||
|
(method === 'GET' && response.status === 404 && !isResourceByIdRequest) ||
|
||||||
(actualMethod === 'OPTIONS' && response.status !== 200))) {
|
(actualMethod === 'OPTIONS' && response.status !== 200))) {
|
||||||
// These are real errors worth logging
|
// These are real errors worth logging
|
||||||
console.warn(`[API Monitor] ${method} ${path}: ${response.status}`, responseText.substring(0, 100));
|
console.warn(`[API Monitor] ${method} ${path}: ${response.status}`, responseText.substring(0, 100));
|
||||||
@@ -622,10 +694,43 @@ export default function ApiMonitor() {
|
|||||||
const getGroupHealth = (group: EndpointGroup) => {
|
const getGroupHealth = (group: EndpointGroup) => {
|
||||||
const statuses = group.endpoints.map(ep => getEndpointStatus(ep.path, ep.method).status);
|
const statuses = group.endpoints.map(ep => getEndpointStatus(ep.path, ep.method).status);
|
||||||
const healthy = statuses.filter(s => s === 'healthy').length;
|
const healthy = statuses.filter(s => s === 'healthy').length;
|
||||||
|
const warning = statuses.filter(s => s === 'warning').length;
|
||||||
|
const error = statuses.filter(s => s === 'error').length;
|
||||||
const total = statuses.length;
|
const total = statuses.length;
|
||||||
return { healthy, total };
|
return { healthy, warning, error, total };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getGroupStatus = (group: EndpointGroup): 'error' | 'warning' | 'healthy' => {
|
||||||
|
const health = getGroupHealth(group);
|
||||||
|
if (health.error > 0) return 'error';
|
||||||
|
if (health.warning > 0) return 'warning';
|
||||||
|
return 'healthy';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusPriority = (status: 'error' | 'warning' | 'healthy'): number => {
|
||||||
|
switch (status) {
|
||||||
|
case 'error': return 0;
|
||||||
|
case 'warning': return 1;
|
||||||
|
case 'healthy': return 2;
|
||||||
|
default: return 3;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort endpoint groups by status (error > warning > healthy)
|
||||||
|
const sortedEndpointGroups = [...endpointGroups].sort((a, b) => {
|
||||||
|
const statusA = getGroupStatus(a);
|
||||||
|
const statusB = getGroupStatus(b);
|
||||||
|
const priorityA = getStatusPriority(statusA);
|
||||||
|
const priorityB = getStatusPriority(statusB);
|
||||||
|
|
||||||
|
// If same priority, sort by name
|
||||||
|
if (priorityA === priorityB) {
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return priorityA - priorityB;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta title="API Monitor - IGNY8" description="API endpoint monitoring" />
|
<PageMeta title="API Monitor - IGNY8" description="API endpoint monitoring" />
|
||||||
@@ -682,13 +787,27 @@ export default function ApiMonitor() {
|
|||||||
|
|
||||||
{/* Monitoring Tables - 3 per row */}
|
{/* Monitoring Tables - 3 per row */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{endpointGroups.map((group, groupIndex) => {
|
{sortedEndpointGroups.map((group, groupIndex) => {
|
||||||
const groupHealth = getGroupHealth(group);
|
const groupHealth = getGroupHealth(group);
|
||||||
|
const groupStatus = getGroupStatus(group);
|
||||||
return (
|
return (
|
||||||
<ComponentCard
|
<ComponentCard
|
||||||
key={groupIndex}
|
key={groupIndex}
|
||||||
title={group.name}
|
title={
|
||||||
desc={`${groupHealth.healthy}/${groupHealth.total} healthy`}
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{group.name}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${getStatusBadge(groupStatus)}`}>
|
||||||
|
{getStatusIcon(groupStatus)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
desc={
|
||||||
|
groupStatus === 'error'
|
||||||
|
? `${groupHealth.error} error${groupHealth.error !== 1 ? 's' : ''}, ${groupHealth.warning} warning${groupHealth.warning !== 1 ? 's' : ''}, ${groupHealth.healthy} healthy`
|
||||||
|
: groupStatus === 'warning'
|
||||||
|
? `${groupHealth.warning} warning${groupHealth.warning !== 1 ? 's' : ''}, ${groupHealth.healthy} healthy`
|
||||||
|
: `${groupHealth.healthy}/${groupHealth.total} healthy`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
@@ -706,8 +825,24 @@ export default function ApiMonitor() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{group.endpoints.map((endpoint, epIndex) => {
|
{group.endpoints
|
||||||
const status = getEndpointStatus(endpoint.path, endpoint.method);
|
.map((endpoint, epIndex) => ({
|
||||||
|
endpoint,
|
||||||
|
epIndex,
|
||||||
|
status: getEndpointStatus(endpoint.path, endpoint.method),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const priorityA = getStatusPriority(a.status.status);
|
||||||
|
const priorityB = getStatusPriority(b.status.status);
|
||||||
|
// If same priority, sort by method then path
|
||||||
|
if (priorityA === priorityB) {
|
||||||
|
const methodCompare = a.endpoint.method.localeCompare(b.endpoint.method);
|
||||||
|
if (methodCompare !== 0) return methodCompare;
|
||||||
|
return a.endpoint.path.localeCompare(b.endpoint.path);
|
||||||
|
}
|
||||||
|
return priorityA - priorityB;
|
||||||
|
})
|
||||||
|
.map(({ endpoint, epIndex, status }) => {
|
||||||
return (
|
return (
|
||||||
<tr key={epIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
<tr key={epIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
@@ -767,22 +902,26 @@ export default function ApiMonitor() {
|
|||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
<ComponentCard title="Summary" desc="Overall API health statistics">
|
<ComponentCard title="Summary" desc="Overall API health statistics">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
{endpointGroups.map((group, index) => {
|
{sortedEndpointGroups.map((group, index) => {
|
||||||
const groupHealth = getGroupHealth(group);
|
const groupHealth = getGroupHealth(group);
|
||||||
|
const groupStatus = getGroupStatus(group);
|
||||||
const percentage = groupHealth.total > 0
|
const percentage = groupHealth.total > 0
|
||||||
? Math.round((groupHealth.healthy / groupHealth.total) * 100)
|
? Math.round((groupHealth.healthy / groupHealth.total) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="text-center">
|
<div key={index} className="text-center">
|
||||||
<div className="text-2xl font-semibold text-gray-800 dark:text-white/90">
|
<div className={`text-2xl font-semibold ${getStatusColor(groupStatus)}`}>
|
||||||
{percentage}%
|
{percentage}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1 flex items-center justify-center gap-1">
|
||||||
{group.name}
|
<span>{group.name}</span>
|
||||||
|
<span className={getStatusColor(groupStatus)}>{getStatusIcon(groupStatus)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||||
{groupHealth.healthy}/{groupHealth.total} healthy
|
{groupHealth.healthy}/{groupHealth.total} healthy
|
||||||
|
{groupHealth.error > 0 && ` • ${groupHealth.error} error${groupHealth.error !== 1 ? 's' : ''}`}
|
||||||
|
{groupHealth.warning > 0 && ` • ${groupHealth.warning} warning${groupHealth.warning !== 1 ? 's' : ''}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -640,18 +640,33 @@ export async function autoClusterKeywords(keywordIds: number[], sectorId?: numbe
|
|||||||
const requestBody = { ids: keywordIds, sector_id: sectorId };
|
const requestBody = { ids: keywordIds, sector_id: sectorId };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// fetchAPI will automatically extract data from unified format
|
||||||
|
// For action endpoints, response is {success: true, data: {...}}
|
||||||
|
// fetchAPI extracts and returns the data field, so response should already be the data object
|
||||||
const response = await fetchAPI(endpoint, {
|
const response = await fetchAPI(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(requestBody),
|
body: JSON.stringify(requestBody),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if response indicates an error (success: false)
|
// After fetchAPI processing, response should be the data object (not wrapped in success/data)
|
||||||
if (response && response.success === false) {
|
// But check if it's still wrapped (shouldn't happen, but for safety)
|
||||||
// Return error response as-is so caller can check result.success
|
if (response && typeof response === 'object') {
|
||||||
return response;
|
if ('success' in response && response.success === false) {
|
||||||
|
// Error response - return as-is
|
||||||
|
return response as any;
|
||||||
|
}
|
||||||
|
// If response has data field, extract it
|
||||||
|
if ('data' in response && response.data) {
|
||||||
|
return { success: true, ...response.data } as any;
|
||||||
|
}
|
||||||
|
// Response is already the data object (after fetchAPI extraction)
|
||||||
|
// Ensure it has success: true
|
||||||
|
if (!('success' in response)) {
|
||||||
|
return { success: true, ...response } as any;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response as any;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user