diff --git a/src/backend/api/database/categories.json b/src/backend/api/database/categories.json index b45d2613..eac0ffee 100644 --- a/src/backend/api/database/categories.json +++ b/src/backend/api/database/categories.json @@ -1,5 +1,6 @@ { "Categories": [ + "Summary", "Installability", "Popularity", "Activity", @@ -16,5 +17,4 @@ "Raw Metrics (Measured via scc)", "Repo Metrics (Measured via GitHub)" ] - } diff --git a/src/backend/api/database/library_metric_values/views.py b/src/backend/api/database/library_metric_values/views.py index 27bd1f36..450e5967 100644 --- a/src/backend/api/database/library_metric_values/views.py +++ b/src/backend/api/database/library_metric_values/views.py @@ -15,7 +15,7 @@ from ...utils.analysis import enqueue_library_analysis from ..domain.models import Domain from ..libraries.models import Library -from ..metrics.models import Metric +from ..metrics.models import Metric, MetricOrder from .models import LibraryMetricValue @@ -184,10 +184,38 @@ def analyze_domain_libraries(request, domain_id): @api_view(["GET"]) def domain_comparison(request, domain_id): domain = get_object_or_404(Domain, pk=domain_id) + path = os.path.join(settings.BASE_DIR, "api", "database", "categories.json") libraries = Library.objects.filter(domain=domain) metrics = Metric.objects.all() + categories_order = [] + with open(path, "r") as f: + categories_order = json.load(f).get("Categories", []) + + # Sort metrics based on MetricOrder + metric_order_obj = MetricOrder.objects.first() + if metric_order_obj and metric_order_obj.category_order: + metrics_category = metric_order_obj.category_order + category_ordered = [ + cat + for cat in categories_order + if cat in metrics_category and metrics_category[cat] + ] + ordered_metric_ids = [] + + for cat in category_ordered: + ordered_metric_ids.extend(metrics_category[cat]) + # Create position dict + position = {mid: i for i, mid in enumerate(ordered_metric_ids)} + # Sort metrics + metrics = sorted( + metrics, + key=lambda m: position.get(str(m.metric_ID), len(ordered_metric_ids)), + ) + else: + metrics = list(metrics) + table = [] by_lib = {} diff --git a/src/backend/api/database/metrics/management/__init__.py b/src/backend/api/database/metrics/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/api/database/metrics/management/commands/__init__.py b/src/backend/api/database/metrics/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/backend/api/database/metrics/management/commands/populate_metric_order.py b/src/backend/api/database/metrics/management/commands/populate_metric_order.py new file mode 100644 index 00000000..2add8b85 --- /dev/null +++ b/src/backend/api/database/metrics/management/commands/populate_metric_order.py @@ -0,0 +1,47 @@ +from django.core.management.base import BaseCommand + +from api.database.metrics.models import Metric, MetricOrder + + +class Command(BaseCommand): + help = "Populate MetricOrder with current metrics organized by category" + + def handle(self, *args, **options): + try: + # Get all metrics grouped by category + metrics = Metric.objects.all().order_by("category", "metric_name") + + category_order = {} + for metric in metrics: + category = metric.category or "Uncategorized" + if category not in category_order: + category_order[category] = [] + category_order[category].append(str(metric.metric_ID)) + + # Get or create the MetricOrder instance + metric_order, created = MetricOrder.objects.get_or_create(pk=1) + metric_order.category_order = category_order + metric_order.save() + + if created: + self.stdout.write( + self.style.SUCCESS( + f"✓ Created MetricOrder with {len(metrics)} metrics" + ) + ) + else: + self.stdout.write( + self.style.SUCCESS( + f"✓ Updated MetricOrder with {len(metrics)} metrics" + ) + ) + + # Print summary + self.stdout.write("\nMetrics by category:") + for category, metric_ids in sorted(category_order.items()): + self.stdout.write(f" {category}: {len(metric_ids)} metrics") + + except Exception as e: + self.stdout.write( + self.style.ERROR(f"✗ Error populating MetricOrder: {str(e)}") + ) diff --git a/src/backend/api/database/metrics/migrations/0009_metricorder.py b/src/backend/api/database/metrics/migrations/0009_metricorder.py new file mode 100644 index 00000000..f69c9980 --- /dev/null +++ b/src/backend/api/database/metrics/migrations/0009_metricorder.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.7 on 2026-05-04 02:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("metrics", "0008_alter_metric_value_type"), + ] + + operations = [ + migrations.CreateModel( + name="MetricOrder", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "category_order", + models.JSONField( + default=dict, + help_text="JSON object mapping category names to ordered lists of metric IDs", + ), + ), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name_plural": "Metric Orders", + }, + ), + ] diff --git a/src/backend/api/database/metrics/models.py b/src/backend/api/database/metrics/models.py index 79ff7bf5..3b55116d 100644 --- a/src/backend/api/database/metrics/models.py +++ b/src/backend/api/database/metrics/models.py @@ -39,3 +39,19 @@ class Metric(models.Model): def __str__(self): return self.metric_name + + +class MetricOrder(models.Model): + """Stores the display order of metrics by category""" + + category_order = models.JSONField( + default=dict, + help_text="JSON object mapping category names to ordered lists of metric IDs", + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name_plural = "Metric Orders" + + def __str__(self): + return f"Metric Order (updated {self.updated_at})" diff --git a/src/backend/api/database/metrics/urls.py b/src/backend/api/database/metrics/urls.py index c0f877a8..d6c002e6 100644 --- a/src/backend/api/database/metrics/urls.py +++ b/src/backend/api/database/metrics/urls.py @@ -5,6 +5,7 @@ MetricCategoryView, MetricListCreateView, MetricListFlatView, + MetricReorderView, MetricRetrieveUpdateDestroyView, MetricRulesView, MetricUpdateWeightView, @@ -14,6 +15,7 @@ path("", MetricListCreateView.as_view(), name="metric-list-create"), path("auto-options/", AutoMetricOptionsView.as_view(), name="metric-auto-options"), path("all/", MetricListFlatView.as_view(), name="metric-all-flat"), + path("reorder/", MetricReorderView.as_view(), name="metric-reorder"), path("rules/", MetricRulesView.as_view(), name="metric-rules"), path("categories/", MetricCategoryView.as_view(), name="metric-categories"), path( diff --git a/src/backend/api/database/metrics/views.py b/src/backend/api/database/metrics/views.py index e89635ba..2f0ddf69 100644 --- a/src/backend/api/database/metrics/views.py +++ b/src/backend/api/database/metrics/views.py @@ -1,4 +1,5 @@ import json +import logging import os from django.conf import settings @@ -8,9 +9,11 @@ from rest_framework.response import Response from rest_framework.views import APIView -from .models import Metric +from .models import Metric, MetricOrder from .serializers import FlatMetricSerializer, MetricSerializer +logger = logging.getLogger(__name__) + def load_auto_metric_definitions(): path = os.path.join(settings.BASE_DIR, "api", "database", "auto_metrics.json") @@ -134,3 +137,78 @@ def patch(self, request, metric_id): class MetricListFlatView(generics.ListAPIView): serializer_class = FlatMetricSerializer queryset = Metric.objects.all().order_by("metric_name") + + +class MetricReorderView(APIView): + permission_classes = [IsAuthenticatedOrReadOnly] # noqa: F811 + + def get(self, request): + """Retrieve the current metric display order""" + try: + metric_order = MetricOrder.objects.first() + if metric_order: + return Response( + {"category_order": metric_order.category_order}, + status=status.HTTP_200_OK, + ) + else: + return Response( + {"category_order": {}}, + status=status.HTTP_200_OK, + ) + except Exception as e: + logger.exception("Failed to retrieve metric display order: %s", e) + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + def post(self, request): + """Save the metric display order""" + try: + category_order = request.data.get("category_order") + if category_order is None: + return Response( + {"error": "category_order field is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate that all metric IDs exist + all_metric_ids = set() + for category_metrics in category_order.values(): + if isinstance(category_metrics, list): + all_metric_ids.update(category_metrics) + + # Check if all metric IDs exist + existing_metrics = set( + str(m_id) + for m_id in Metric.objects.filter( + metric_ID__in=all_metric_ids + ).values_list("metric_ID", flat=True) + ) + + invalid_ids = all_metric_ids - existing_metrics + if invalid_ids: + return Response( + {"error": f"Invalid metric IDs: {list(invalid_ids)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get or create the single MetricOrder instance + metric_order, _ = MetricOrder.objects.get_or_create(pk=1) + metric_order.category_order = category_order + metric_order.save() + + return Response( + { + "message": "Metric display order updated successfully", + "category_order": metric_order.category_order, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + logger.exception("Failed to save metric display order %s", e) + return Response( + {"error": "An internal error has occurred."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) diff --git a/src/frontend/src/components/AddMetricModal.tsx b/src/frontend/src/components/AddMetricModal.tsx new file mode 100644 index 00000000..6fb954bb --- /dev/null +++ b/src/frontend/src/components/AddMetricModal.tsx @@ -0,0 +1,425 @@ +import { AddMetricModalProps } from "../pages/MetricPageTypes"; + +export const AddMetricModal: React.FC = ({ + isOpen, + modalMode, + categories, + formError, + modalSourceType, + modalMetricKey, + modalType, + modalAutoOptions, + modalAvailableCats, + modalOptionCategory, + modalPreview, + newName, + newType, + newSourceType, + newMetricKey, + newCategory, + newDesc, + selectedOptionCategory, + selectedTemplate, + editName, + editType, + editSourceType, + editMetricKey, + editCategory, + editDesc, + editOptionCategory, + editTemplate, + closeModal, + onSubmit, + setFormError, + setNewName, + setEditName, + setNewType, + setEditType, + setNewSourceType, + setEditSourceType, + setNewMetricKey, + setEditMetricKey, + setNewCategory, + setEditCategory, + setNewDesc, + setEditDesc, + setSelectedOptionCategory, + setSelectedTemplate, + setEditOptionCategory, + setEditTemplate, + onEditTypeChange, + isRuleType, +}) => { + if (!isOpen) return null; + + const isCreate = modalMode === "create"; + const title = isCreate ? "Add New Metric" : "Edit Metric"; + const nameValue = isCreate ? newName : editName; + const typeValue = isCreate ? newType : editType; + const sourceTypeValue = isCreate ? newSourceType : editSourceType; + const metricKeyValue = isCreate ? newMetricKey : editMetricKey; + const categoryValue = isCreate ? newCategory : editCategory; + const descValue = isCreate ? newDesc : editDesc; + const optionCategoryValue = isCreate ? selectedOptionCategory : editOptionCategory; + const templateValue = isCreate ? selectedTemplate : editTemplate; + + const changeSourceType = (value: string) => { + setFormError(""); + if (isCreate) { + setNewSourceType(value); + setNewMetricKey(""); + setSelectedOptionCategory(""); + setSelectedTemplate(""); + if (value === "manual") { + setNewType("float"); + setNewDesc(""); + } + } else { + setEditSourceType(value); + setEditMetricKey(""); + setEditOptionCategory(""); + setEditTemplate(""); + if (value === "manual") { + setEditType("float"); + setEditDesc(""); + } + } + }; + + const changeMetricKey = (selectedKey: string) => { + const selectedOption = modalAutoOptions.find((x) => x.key === selectedKey); + setFormError(""); + + if (isCreate) { + setNewMetricKey(selectedKey); + setSelectedOptionCategory(""); + setSelectedTemplate(""); + if (selectedOption) { + setNewType(selectedOption.value_type); + setNewDesc(selectedOption.description || ""); + } + } else { + setEditMetricKey(selectedKey); + setEditOptionCategory(""); + setEditTemplate(""); + if (selectedOption) { + setEditType(selectedOption.value_type); + setEditDesc(selectedOption.description || ""); + } + } + }; + + const handleSubmit = async () => { + const ok = await onSubmit(); + if (ok) closeModal(); + }; + + return ( +
{ + if (e.target === e.currentTarget) closeModal(); + }} + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + backgroundColor: "rgba(0, 0, 0, 0.6)", + zIndex: 9999, + }} + > +
e.stopPropagation()} + style={{ + width: "min(900px, 92vw)", + maxHeight: "85vh", + overflow: "auto", + padding: 18, + position: "relative", + background: "rgba(18, 18, 26, 0.98)", + border: "1px solid rgba(255,255,255,0.08)", + borderRadius: 16, + boxShadow: "0px 10px 25px rgba(0, 0, 0, 0.2)", + }} + > +
+
+ {title} +
+ + +
+ + {formError && ( +
+ {formError} +
+ )} + +
+
+ + { + setFormError(""); + isCreate ? setNewName(e.target.value) : setEditName(e.target.value); + }} + maxLength={100} + placeholder="e.g. Commits (Last 5 Years)" + /> +
+ +
+ + +
+ + {modalSourceType !== "manual" && ( +
+ + +
+ )} + +
+ + +
+ +
+ + +
+ +
+ + {modalSourceType === "manual" && isRuleType(modalType) && ( +
+
+ + +
+ +
+ + +
+ + {modalPreview && ( +
+
+ Rule Preview +
+ + {JSON.stringify(modalPreview, null, 2)} + +
+ )} +
+ )} + +
+ +