Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
259 changes: 250 additions & 9 deletions django-backend/soroscan/ingest/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
ContractVerification,
DataDeletionRequest,
DataRetentionPolicy,
EventAggregation,
EventSchema,
IndexerState,
IngestError,
Expand All @@ -47,9 +48,11 @@
PIIField,
RemediationIncident,
RemediationRule,
SigningKey,
Team,
TeamMembership,
TrackedContract,
TransactionCost,
WebhookDeadLetter,
WebhookDeliveryLog,
WebhookSubscription,
Expand Down Expand Up @@ -676,18 +679,14 @@ def ping_webhook(self, request, pk):
"contract_id": webhook.contract.contract_id,
"timestamp": timezone.now().isoformat(),
}
payload_bytes = json.dumps(test_payload, sort_keys=True).encode("utf-8")
payload_str = json.dumps(test_payload, sort_keys=True)
payload_bytes = payload_str.encode("utf-8")
algorithm = (webhook.signature_algorithm or WebhookSubscription.SIGNATURE_SHA256).lower()
digestmod = hashlib.sha1 if algorithm == WebhookSubscription.SIGNATURE_SHA1 else hashlib.sha256
prefix = "sha1" if algorithm == WebhookSubscription.SIGNATURE_SHA1 else "sha256"
sig_hex = hmac.new(
webhook.secret.encode("utf-8"),
msg=payload_bytes,
digestmod=digestmod,
).hexdigest()
from .webhook_signing import sign_webhook_payload

headers = {
"Content-Type": "application/json",
"X-SoroScan-Signature": f"{prefix}={sig_hex}",
"X-SoroScan-Signature": sign_webhook_payload(payload_str, webhook.secret, algorithm=algorithm),
"X-SoroScan-Timestamp": timezone.now().isoformat(),
}
try:
Expand Down Expand Up @@ -1418,3 +1417,245 @@ def test_dedup_view(self, request, contract_id):
json.dumps({"dedup_hash": dedup_hash, "material": material}),
content_type="application/json",
)


# ---------------------------------------------------------------------------
# Transaction Cost Analysis (Issue #804)
# ---------------------------------------------------------------------------


@admin.register(TransactionCost)
class TransactionCostAdmin(admin.ModelAdmin):
list_display = [
"tx_hash_short",
"contract",
"function_name",
"total_fee_stroops",
"is_outlier",
"ledger_sequence",
"created_at",
]
list_filter = ["is_outlier", "function_name", "created_at"]
search_fields = [
"tx_hash",
"contract__contract_id",
"contract__name",
"function_name",
]
readonly_fields = [
"tx_hash",
"contract",
"function_name",
"ledger_sequence",
"total_fee_stroops",
"cpu_instructions_used",
"memory_bytes_used",
"network_bytes_used",
"is_outlier",
"created_at",
]

def tx_hash_short(self, obj):
return obj.tx_hash[:16] + "..."
tx_hash_short.short_description = "TX Hash"
tx_hash_short.admin_order_field = "tx_hash"

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


# ---------------------------------------------------------------------------
# Event Aggregation Analytics (Issue #801)
# ---------------------------------------------------------------------------


@admin.register(EventAggregation)
class EventAggregationAdmin(admin.ModelAdmin):
list_display = [
"contract",
"event_type",
"time_bucket",
"event_count",
"created_at",
]
list_filter = ["event_type", "time_bucket"]
search_fields = [
"contract__contract_id",
"contract__name",
"event_type",
]
readonly_fields = [
"contract",
"event_type",
"time_bucket",
"event_count",
"created_at",
]
date_hierarchy = "time_bucket"

def has_add_permission(self, request):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


# ---------------------------------------------------------------------------
# Analytics Dashboard View
# ---------------------------------------------------------------------------


from django.template.response import TemplateResponse
from django.db.models import Count as _Count, Sum as _Sum
from django.urls import path as _path
from django.utils import timezone as _tz


def analytics_dashboard_view(request):
"""
Admin view for the analytics dashboard.
Displays summary widgets for event volume, active contracts, and event types.
"""
now = _tz.now()
day_ago = now - timedelta(days=1)
week_ago = now - timedelta(days=7)
month_ago = now - timedelta(days=30)

total_events_24h = (
EventAggregation.objects.filter(time_bucket__gte=day_ago)
.aggregate(total=_Sum("event_count"))["total"] or 0
)
total_events_7d = (
EventAggregation.objects.filter(time_bucket__gte=week_ago)
.aggregate(total=_Sum("event_count"))["total"] or 0
)
total_events_30d = (
EventAggregation.objects.filter(time_bucket__gte=month_ago)
.aggregate(total=_Sum("event_count"))["total"] or 0
)

active_contracts_24h = (
EventAggregation.objects.filter(time_bucket__gte=day_ago)
.values("contract_id")
.distinct()
.count()
)
active_contracts_7d = (
EventAggregation.objects.filter(time_bucket__gte=week_ago)
.values("contract_id")
.distinct()
.count()
)

daily_volume = list(
EventAggregation.objects.filter(time_bucket__gte=month_ago)
.values("time_bucket")
.annotate(count=_Sum("event_count"))
.order_by("time_bucket")[:30]
)

event_type_breakdown = list(
EventAggregation.objects.filter(time_bucket__gte=week_ago)
.values("event_type")
.annotate(count=_Sum("event_count"))
.order_by("-count")[:10]
)

top_contracts = list(
EventAggregation.objects.filter(time_bucket__gte=week_ago)
.values("contract__contract_id", "contract__name")
.annotate(count=_Sum("event_count"))
.order_by("-count")[:10]
)

extra_context = (
request.contextual_help_context()
if hasattr(request, "contextual_help_context")
else {}
)
context = {
**extra_context,
"title": "Analytics Dashboard",
"total_events_24h": total_events_24h,
"total_events_7d": total_events_7d,
"total_events_30d": total_events_30d,
"active_contracts_24h": active_contracts_24h,
"active_contracts_7d": active_contracts_7d,
"daily_volume": [
{
"date": r["time_bucket"].strftime("%Y-%m-%d") if hasattr(r["time_bucket"], "strftime") else str(r["time_bucket"]),
"count": r["count"],
}
for r in daily_volume
],
"event_type_breakdown": [
{"event_type": r["event_type"], "count": r["count"]}
for r in event_type_breakdown
],
"top_contracts": [
{
"contract_id": r["contract__contract_id"],
"name": r["contract__name"],
"count": r["count"],
}
for r in top_contracts
],
}

return TemplateResponse(request, "admin/analytics_dashboard.html", context)


@admin.register(SigningKey)
class SigningKeyAdmin(admin.ModelAdmin):
list_display = ["subscription", "label", "is_active", "expires_at", "created_at"]
list_filter = ["is_active", "subscription"]
search_fields = ["label", "key"]
readonly_fields = ["key", "created_at"]
raw_id_fields = ["subscription"]

fieldsets = [
(None, {"fields": ["subscription", "label"]}),
("Key Material", {"fields": ["key", "is_active", "expires_at", "created_at"]}),
]

def save_model(self, request, obj, form, change):
from .webhook_signing import generate_signing_key

if not obj.key:
obj.key = generate_signing_key()
super().save_model(request, obj, form, change)

def get_queryset(self, request):
return super().get_queryset(request).select_related("subscription")


# Register analytics dashboard URL with the admin site
from django.contrib import admin as _admin_site

_original_get_urls = _admin_site.site.get_urls


def _extended_get_urls():
from django.urls import path as _url_path
urls = _original_get_urls()
urls.insert(
0,
_url_path(
"analytics-dashboard/",
_admin_site.site.admin_view(analytics_dashboard_view),
name="analytics-dashboard",
),
)
return urls


_admin_site.site.get_urls = _extended_get_urls
73 changes: 73 additions & 0 deletions django-backend/soroscan/ingest/migrations/0042_apiusagelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("ingest", "0041_eventdeduplicationconfig"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="APIUsageLog",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("method", models.CharField(max_length=12)),
("endpoint", models.CharField(db_index=True, max_length=255)),
("path", models.CharField(max_length=512)),
("status_code", models.PositiveSmallIntegerField(db_index=True)),
("request_bytes", models.PositiveIntegerField(default=0)),
("response_bytes", models.PositiveIntegerField(default=0)),
("error_type", models.CharField(blank=True, db_index=True, max_length=64)),
("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)),
(
"api_key",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="usage_logs",
to="ingest.apikey",
),
),
(
"organization",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="api_usage_logs",
to="ingest.organization",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="api_usage_logs",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-timestamp"],
"indexes": [
models.Index(fields=["organization", "timestamp"], name="ingest_apiu_organiz_f4df26_idx"),
models.Index(fields=["organization", "endpoint", "timestamp"], name="ingest_apiu_organiz_7f7db3_idx"),
models.Index(fields=["organization", "error_type", "timestamp"], name="ingest_apiu_organiz_76c671_idx"),
],
},
),
]
Loading
Loading