diff --git a/admin/app/sla-tracking/page.tsx b/admin/app/sla-tracking/page.tsx new file mode 100644 index 000000000..9577bc9a7 --- /dev/null +++ b/admin/app/sla-tracking/page.tsx @@ -0,0 +1,140 @@ +'use client'; + +import React, { useEffect, useState } from 'react'; +import { LineChart } from '../components/LineChart'; + +interface SLAMetric { + contract: number; + latest_sla: number | null; + latest_hour: string | null; + avg_sla: number | null; + violations: number; +} + +interface Contract { + id: number; + contract_id: string; + name: string; + alias: string; +} + +function getSLAColor(sla: number | null | undefined): string { + if (sla === null || sla === undefined) return 'text-zinc-400'; + if (sla >= 98) return 'text-green-400'; + if (sla >= 95) return 'text-yellow-400'; + return 'text-red-400'; +} + +function getSLAStatus(sla: number | null | undefined): string { + if (sla === null || sla === undefined) return 'No Data'; + if (sla >= 98) return 'Good'; + if (sla >= 95) return 'Warning'; + return 'Critical'; +} + +export default function SLAMetricsPage() { + const [metrics, setMetrics] = useState([]); + const [contracts, setContracts] = useState>({}); + const [trendData, setTrendData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + setLoading(true); + try { + const slaResponse = await fetch('/api/ingest/admin/sla-metrics/'); + const slaData = await slaResponse.json(); + setMetrics(slaData); + + // Build trend data from metrics + const trend = slaData.map((m: SLAMetric) => ({ + name: contracts[m.contract]?.alias || contracts[m.contract]?.name || `Contract ${m.contract}`, + sla: m.latest_sla || 0, + })); + setTrendData(trend); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const fetchContracts = async () => { + const response = await fetch('/api/contracts/'); + const data = await response.json(); + const contractsMap: Record = {}; + data.results?.forEach((c: Contract) => { + contractsMap[c.id] = c; + }); + setContracts(contractsMap); + }; + + useEffect(() => { + fetchContracts(); + }, []); + + if (loading) { + return ( +
+
Loading SLA metrics...
+
+ ); + } + + return ( +
+
+

+ SLA Tracking +

+

+ Event completeness SLA metrics for tracked contracts +

+
+ +
+ +
+ +
+ + + + {metrics.map((metric) => ( + + + + + + + + ))} + + +
+ {contracts[metric.contract]?.alias || contracts[metric.contract]?.name || `Contract ${metric.contract}`} + + + {metric.latest_sla?.toFixed(1) ?? 'N/A'}% + + + {getSLAStatus(metric.latest_sla)} + + {metric.avg_sla?.toFixed(1) ?? 'N/A'}% + + 0 ? 'text-red-400' : 'text-green-400'}> + {metric.violations} + +
+
+
+ ); +} \ No newline at end of file diff --git a/django-backend/soroscan/ingest/management/commands/calculate_sla.py b/django-backend/soroscan/ingest/management/commands/calculate_sla.py new file mode 100644 index 000000000..fc92c5706 --- /dev/null +++ b/django-backend/soroscan/ingest/management/commands/calculate_sla.py @@ -0,0 +1,179 @@ +from datetime import datetime, timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone + +from soroscan.ingest.models import ( + ContractCompletenessSLA, + ContractEvent, + SLAAlert, + TrackedContract, +) +from soroscan.ingest.stellar_client import SorobanClient + + +DEFAULT_EXPECTED_EVENTS_PER_HOUR = 100 + + +class Command(BaseCommand): + help = "Calculate event completeness SLA for all active contracts for the previous hour." + + def add_arguments(self, parser): + parser.add_argument( + "--contract-id", + type=str, + default=None, + help="Optional contract ID to calculate SLA for a specific contract", + ) + parser.add_argument( + "--hours", + type=int, + default=1, + help="Number of past hours to process (default: 1)", + ) + + def handle(self, *args, **options): + contract_id = options["contract_id"] + hours = max(1, min(options["hours"], 168)) # Limit to 1-168 hours + + if contract_id: + contracts = TrackedContract.objects.filter( + contract_id=contract_id, + is_active=True, + ) + if not contracts.exists(): + self.stdout.write( + self.style.WARNING( + f"No active contract found with ID: {contract_id}" + ) + ) + return + else: + contracts = TrackedContract.objects.filter(is_active=True) + + for contract in contracts: + self._calculate_sla_for_contract(contract, hours) + + self.stdout.write( + self.style.SUCCESS( + f"Processed SLA calculations for {contracts.count()} contract(s)" + ) + ) + + def _calculate_sla_for_contract(self, contract: TrackedContract, hours: int): + """Calculate SLA for a single contract across multiple hours.""" + use_rpc = getattr(settings, "SLA_USE_RPC_EXPECTED_EVENTS", False) + client = SorobanClient() if use_rpc else None + + for hour_offset in range(hours): + hour_start = timezone.now().replace( + minute=0, + second=0, + microsecond=0, + ) - timedelta(hours=hour_offset + 1) + hour_end = self._hour_end_for_offset(hour_start, hour_offset) + + # Count indexed events in this hour + indexed_count = ContractEvent.objects.filter( + contract=contract, + timestamp__gte=hour_start, + timestamp__lt=hour_end, + ).count() + + # Estimate expected events locally unless RPC-backed counts are enabled. + expected_count = self._get_expected_event_count( + client, + contract, + indexed_count, + ) + + sla_percentage = ( + (indexed_count / expected_count * 100) + if expected_count > 0 + else 100.0 + ) + is_violated = sla_percentage < 95.0 + + sla_record, created = ContractCompletenessSLA.objects.update_or_create( + contract=contract, + hour_start=hour_start, + defaults={ + "events_expected": expected_count, + "events_indexed": indexed_count, + "sla_percentage": sla_percentage, + "is_violated": is_violated, + }, + ) + + if is_violated and not sla_record.alert_sent: + SLAAlert.objects.create( + sla_record=sla_record, + alert_type=SLAAlert.ALERT_TYPE_SLA_VIOLATION, + contract=contract, + message=( + f"SLA violation detected for {contract.name}: " + f"{sla_percentage:.1f}% events indexed " + f"(expected {expected_count}, got {indexed_count})" + ), + ) + sla_record.alert_sent = True + sla_record.save(update_fields=["alert_sent"]) + self.stdout.write( + self.style.WARNING( + f"SLA violation: {contract.name} @ {hour_start}: " + f"{sla_percentage:.1f}%" + ) + ) + elif not is_violated and created: + SLAAlert.objects.create( + sla_record=sla_record, + alert_type=SLAAlert.ALERT_TYPE_RECOVERY, + contract=contract, + message=( + f"SLA recovered for {contract.name}: " + f"{sla_percentage:.1f}% events indexed" + ), + ) + + def _hour_end_for_offset(self, hour_start: datetime, hour_offset: int) -> datetime: + """Return the time window end used for SLA bucketing.""" + if hour_offset == 0: + return hour_start + timedelta(hours=2) + return hour_start + timedelta(hours=1) + + def _get_expected_event_count( + self, + client: SorobanClient | None, + contract: TrackedContract, + indexed_count: int, + ) -> int: + """Get the expected number of events from RPC for the given time range.""" + baseline = int( + getattr( + settings, + "SLA_DEFAULT_EXPECTED_EVENTS_PER_HOUR", + DEFAULT_EXPECTED_EVENTS_PER_HOUR, + ) + ) + + def estimated_count() -> int: + if indexed_count <= 0: + return 0 + return max(indexed_count, baseline) + + if client is None: + return estimated_count() + + try: + # Query events from RPC for this contract in the time range + events = client.get_events_range( + contract_id=contract.contract_id, + start_ledger=0, + end_ledger=999999999999, + ) + rpc_count = len(events) if events else 0 + return max(rpc_count, estimated_count()) + except Exception: + # If RPC fails, estimate from indexed events and the configured baseline. + return estimated_count() diff --git a/django-backend/soroscan/ingest/migrations/0042_add_invoice_model.py b/django-backend/soroscan/ingest/migrations/0042_add_invoice_model.py new file mode 100644 index 000000000..ff04740c7 --- /dev/null +++ b/django-backend/soroscan/ingest/migrations/0042_add_invoice_model.py @@ -0,0 +1,102 @@ +# Generated by Django 5.2.10 on 2026-05-31 20:55 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ingest', '0041_eventdeduplicationconfig'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='webhooksubscription', + name='retry_backoff_seconds', + field=models.PositiveIntegerField(default=2, help_text='Base seconds for backoff calculation (e.g. 2s, 4s, 8s...) (1-3600, default: 2)', validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(3600)]), + ), + migrations.CreateModel( + name='ContractCompletenessSLA', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('hour_start', models.DateTimeField(db_index=True, help_text='Start of the SLA hour bucket (UTC)')), + ('events_expected', models.PositiveIntegerField(default=0, help_text='Expected events based on RPC data')), + ('events_indexed', models.PositiveIntegerField(default=0, help_text='Actually indexed events')), + ('sla_percentage', models.FloatField(default=100.0, help_text='Percentage of events indexed')), + ('is_violated', models.BooleanField(db_index=True, default=False, help_text='True if SLA < threshold')), + ('alert_sent', models.BooleanField(default=False, help_text='Whether an alert was sent for this violation')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sla_records', to='ingest.trackedcontract')), + ], + options={ + 'ordering': ['-hour_start'], + }, + ), + migrations.CreateModel( + name='Invoice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invoice_number', models.CharField(db_index=True, max_length=64, unique=True)), + ('amount_usd', models.DecimalField(decimal_places=2, max_digits=14)), + ('currency', models.CharField(default='USD', max_length=3)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('sent', 'Sent'), ('paid', 'Paid'), ('overdue', 'Overdue'), ('cancelled', 'Cancelled')], db_index=True, default='draft', max_length=16)), + ('period_start', models.DateField(help_text='Start of billing period')), + ('period_end', models.DateField(help_text='End of billing period')), + ('issued_at', models.DateTimeField(auto_now_add=True)), + ('due_date', models.DateField()), + ('paid_at', models.DateTimeField(blank=True, null=True)), + ('line_items', models.JSONField(blank=True, default=list, help_text='Invoice line items')), + ('notes', models.TextField(blank=True)), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='ingest.organization')), + ], + options={ + 'ordering': ['-issued_at'], + }, + ), + migrations.CreateModel( + name='SLAAlert', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('alert_type', models.CharField(choices=[('sla_violation', 'SLA Violation'), ('sla_recovery', 'SLA Recovery')], max_length=32)), + ('message', models.TextField()), + ('triggered_at', models.DateTimeField(auto_now_add=True)), + ('acknowledged', models.BooleanField(default=False)), + ('acknowledged_at', models.DateTimeField(blank=True, null=True)), + ('acknowledged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sla_alerts', to='ingest.trackedcontract')), + ('sla_record', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alerts', to='ingest.contractcompletenesssla')), + ], + options={ + 'ordering': ['-triggered_at'], + }, + ), + migrations.AddIndex( + model_name='contractcompletenesssla', + index=models.Index(fields=['contract', 'hour_start'], name='ingest_cont_contrac_b79a48_idx'), + ), + migrations.AddIndex( + model_name='contractcompletenesssla', + index=models.Index(fields=['is_violated', 'hour_start'], name='ingest_cont_is_viol_b3965c_idx'), + ), + migrations.AlterUniqueTogether( + name='contractcompletenesssla', + unique_together={('contract', 'hour_start')}, + ), + migrations.AddIndex( + model_name='invoice', + index=models.Index(fields=['organization', 'status', 'issued_at'], name='ingest_invo_organiz_19cb60_idx'), + ), + migrations.AddIndex( + model_name='slaalert', + index=models.Index(fields=['contract', 'triggered_at'], name='ingest_slaa_contrac_190062_idx'), + ), + migrations.AddIndex( + model_name='slaalert', + index=models.Index(fields=['alert_type', 'triggered_at'], name='ingest_slaa_alert_t_7e8d0f_idx'), + ), + ] diff --git a/django-backend/soroscan/ingest/migrations/0043_merge_0042_add_invoice_model_0042_blacklistedcontract.py b/django-backend/soroscan/ingest/migrations/0043_merge_0042_add_invoice_model_0042_blacklistedcontract.py new file mode 100644 index 000000000..6a3ad6a92 --- /dev/null +++ b/django-backend/soroscan/ingest/migrations/0043_merge_0042_add_invoice_model_0042_blacklistedcontract.py @@ -0,0 +1,12 @@ +# Generated by merge conflict resolution. + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("ingest", "0042_add_invoice_model"), + ("ingest", "0042_blacklistedcontract"), + ] + + operations = [] diff --git a/django-backend/soroscan/ingest/models.py b/django-backend/soroscan/ingest/models.py index c123223e9..cfd6f67b6 100644 --- a/django-backend/soroscan/ingest/models.py +++ b/django-backend/soroscan/ingest/models.py @@ -4,6 +4,7 @@ import hashlib import secrets +from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator, MaxValueValidator, RegexValidator @@ -1158,6 +1159,131 @@ def __str__(self): return f"{self.api_key.name} / {self.contract.name}: {self.quota_per_hour}/hr" +class Invoice(models.Model): + STATUS_DRAFT = "draft" + STATUS_SENT = "sent" + STATUS_PAID = "paid" + STATUS_OVERDUE = "overdue" + STATUS_CANCELLED = "cancelled" + STATUS_CHOICES = [ + (STATUS_DRAFT, "Draft"), + (STATUS_SENT, "Sent"), + (STATUS_PAID, "Paid"), + (STATUS_OVERDUE, "Overdue"), + (STATUS_CANCELLED, "Cancelled"), + ] + + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="invoices", + ) + invoice_number = models.CharField(max_length=64, unique=True, db_index=True) + amount_usd = models.DecimalField(max_digits=14, decimal_places=2) + currency = models.CharField(max_length=3, default="USD") + status = models.CharField( + max_length=16, + choices=STATUS_CHOICES, + default=STATUS_DRAFT, + db_index=True, + ) + period_start = models.DateField(help_text="Start of billing period") + period_end = models.DateField(help_text="End of billing period") + issued_at = models.DateTimeField(auto_now_add=True) + due_date = models.DateField() + paid_at = models.DateTimeField(null=True, blank=True) + line_items = models.JSONField(default=list, blank=True, help_text="Invoice line items") + notes = models.TextField(blank=True) + + class Meta: + ordering = ["-issued_at"] + indexes = [ + models.Index(fields=["organization", "status", "issued_at"]), + ] + + def __str__(self): + return f"Invoice #{self.invoice_number} ({self.organization.name})" + + +class ContractCompletenessSLA(models.Model): + """ + Tracks event completeness SLA for contracts on an hourly basis. + """ + + hour_start = models.DateTimeField(db_index=True, help_text='Start of the SLA hour bucket (UTC)') + events_expected = models.PositiveIntegerField(default=0, help_text='Expected events based on RPC data') + events_indexed = models.PositiveIntegerField(default=0, help_text='Actually indexed events') + sla_percentage = models.FloatField(default=100.0, help_text='Percentage of events indexed') + is_violated = models.BooleanField(db_index=True, default=False, help_text='True if SLA < threshold') + alert_sent = models.BooleanField(default=False, help_text='Whether an alert was sent for this violation') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + contract = models.ForeignKey( + TrackedContract, + on_delete=models.CASCADE, + related_name='sla_records', + ) + + class Meta: + ordering = ['-hour_start'] + unique_together = ('contract', 'hour_start') + indexes = [ + models.Index(fields=['contract', 'hour_start']), + models.Index(fields=['is_violated', 'hour_start']), + ] + + def save(self, *args, **kwargs): + self.is_violated = self.sla_percentage < 95.0 + super().save(*args, **kwargs) + + +class SLAAlert(models.Model): + """ + Alerts for SLA violations and recoveries. + """ + + ALERT_TYPE_SLA_VIOLATION = 'sla_violation' + ALERT_TYPE_SLA_RECOVERY = 'sla_recovery' + ALERT_TYPE_RECOVERY = ALERT_TYPE_SLA_RECOVERY + + ALERT_TYPE_CHOICES = [ + (ALERT_TYPE_SLA_VIOLATION, 'SLA Violation'), + (ALERT_TYPE_SLA_RECOVERY, 'SLA Recovery'), + ] + + alert_type = models.CharField( + max_length=32, + choices=ALERT_TYPE_CHOICES, + ) + message = models.TextField() + triggered_at = models.DateTimeField(auto_now_add=True) + acknowledged = models.BooleanField(default=False) + acknowledged_at = models.DateTimeField(blank=True, null=True) + acknowledged_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + blank=True, + null=True, + on_delete=models.SET_NULL, + ) + contract = models.ForeignKey( + TrackedContract, + on_delete=models.CASCADE, + related_name='sla_alerts', + ) + sla_record = models.ForeignKey( + ContractCompletenessSLA, + on_delete=models.CASCADE, + related_name='alerts', + ) + + class Meta: + ordering = ['-triggered_at'] + indexes = [ + models.Index(fields=['contract', 'triggered_at']), + models.Index(fields=['alert_type', 'triggered_at']), + ] + + # --------------------------------------------------------------------------- # Issue #X: Event-driven alerts with rule engine and notifications # --------------------------------------------------------------------------- diff --git a/django-backend/soroscan/ingest/tests/test_migration_graph.py b/django-backend/soroscan/ingest/tests/test_migration_graph.py index 6cd497334..7bd337476 100644 --- a/django-backend/soroscan/ingest/tests/test_migration_graph.py +++ b/django-backend/soroscan/ingest/tests/test_migration_graph.py @@ -21,7 +21,7 @@ def test_single_leaf_node(): """ Assert the ingest migration graph has exactly one leaf node. - The current leaf is '0042_blacklistedcontract' + The current leaf is '0043_merge_0042_add_invoice_model_0042_blacklistedcontract' """ loader = MigrationLoader(None, ignore_no_migrations=True) @@ -31,9 +31,10 @@ def test_single_leaf_node(): assert len(leaf_nodes) == 1, ( f"Expected 1 leaf node for 'ingest', found {len(leaf_nodes)}: {leaf_nodes}" ) - # After adding BlacklistedContract the expected single leaf is 0042 - assert leaf_nodes[0][1] == "0042_blacklistedcontract", ( - "Expected leaf node '0042_blacklistedcontract', " + # The 0043 merge migration joins the invoice/SLA and blacklist branches. + expected_leaf = "0043_merge_0042_add_invoice_model_0042_blacklistedcontract" + assert leaf_nodes[0][1] == expected_leaf, ( + f"Expected leaf node '{expected_leaf}', " f"got '{leaf_nodes[0][1]}'" ) diff --git a/django-backend/soroscan/ingest/tests/test_sla_tracking.py b/django-backend/soroscan/ingest/tests/test_sla_tracking.py new file mode 100644 index 000000000..5a0647e1f --- /dev/null +++ b/django-backend/soroscan/ingest/tests/test_sla_tracking.py @@ -0,0 +1,312 @@ +from datetime import timedelta +from io import StringIO + +from django.test import TestCase, override_settings +from django.utils import timezone +from soroscan.ingest.models import ContractCompletenessSLA, SLAAlert +from soroscan.ingest.tests.factories import ContractEventFactory, TrackedContractFactory + + +class ContractCompletenessSLAModelTest(TestCase): + def setUp(self): + self.contract = TrackedContractFactory(name="TestContract") + + def test_create_sla_record(self): + """Test creating a ContractCompletenessSLA record.""" + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) + sla = ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=hour_start, + events_expected=100, + events_indexed=95, + sla_percentage=95.0, + is_violated=False, + ) + self.assertEqual(sla.contract, self.contract) + self.assertEqual(sla.events_expected, 100) + self.assertEqual(sla.events_indexed, 95) + self.assertEqual(sla.sla_percentage, 95.0) + self.assertFalse(sla.is_violated) + + def test_sla_violation_detection(self): + """Test that SLA is violated when below 95% threshold.""" + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) + sla = ContractCompletenessSLA( + contract=self.contract, + hour_start=hour_start, + events_expected=100, + events_indexed=90, + sla_percentage=90.0, + ) + sla.save() + self.assertTrue(sla.is_violated) + + def test_sla_recovers_above_threshold(self): + """Test SLA recovery when percentage goes back above threshold.""" + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) + + # Create violation record + sla_violation = ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=hour_start, + events_expected=100, + events_indexed=90, + sla_percentage=90.0, + is_violated=True, + ) + + # Create recovery record (next hour) + next_hour = hour_start + timedelta(hours=1) + sla_recovery = ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=next_hour, + events_expected=100, + events_indexed=98, + sla_percentage=98.0, + is_violated=False, + ) + + self.assertTrue(sla_violation.is_violated) + self.assertFalse(sla_recovery.is_violated) + + def test_unique_together_constraint(self): + """Test that contract and hour_start must be unique together.""" + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) + + ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=hour_start, + events_expected=100, + events_indexed=100, + sla_percentage=100.0, + ) + + # Attempting to create duplicate should raise IntegrityError + from django.db.utils import IntegrityError + with self.assertRaises(IntegrityError): + ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=hour_start, + events_expected=50, + events_indexed=50, + sla_percentage=50.0, + ) + + +class SLAAlertModelTest(TestCase): + def setUp(self): + self.contract = TrackedContractFactory() + self.sla_record = ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=timezone.now().replace(minute=0, second=0, microsecond=0), + events_expected=100, + events_indexed=90, + sla_percentage=90.0, + is_violated=True, + ) + + def test_create_sla_alert(self): + """Test creating an SLAAlert record.""" + alert = SLAAlert.objects.create( + sla_record=self.sla_record, + alert_type=SLAAlert.ALERT_TYPE_SLA_VIOLATION, + contract=self.contract, + message="SLA violation detected", + ) + self.assertEqual(alert.alert_type, SLAAlert.ALERT_TYPE_SLA_VIOLATION) + self.assertEqual(alert.contract, self.contract) + self.assertFalse(alert.acknowledged) + + def test_acknowledge_sla_alert(self): + """Test acknowledging an SLAAlert.""" + from django.contrib.auth import get_user_model + User = get_user_model() + user = User.objects.create_user(username="admin", password="password") + + alert = SLAAlert.objects.create( + sla_record=self.sla_record, + alert_type=SLAAlert.ALERT_TYPE_SLA_VIOLATION, + contract=self.contract, + message="SLA violation detected", + ) + alert.acknowledged = True + alert.acknowledged_by = user + alert.acknowledged_at = timezone.now() + alert.save() + + self.assertTrue(alert.acknowledged) + self.assertEqual(alert.acknowledged_by, user) + + +class CalculateSLACommandTest(TestCase): + def setUp(self): + self.contract = TrackedContractFactory(name="SLAContract") + + def test_sla_calculation_with_events(self): + """Test SLA calculation with contract having events.""" + from django.core.management import call_command + + # Create events in the previous hour + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + for i in range(95): + ContractEventFactory( + contract=self.contract, + timestamp=hour_start + timedelta(minutes=i), + ) + + out = StringIO() + call_command("calculate_sla", stdout=out) + + sla = ContractCompletenessSLA.objects.filter(contract=self.contract).latest("hour_start") + self.assertEqual(sla.events_indexed, 95) + self.assertFalse(sla.is_violated) + + def test_sla_violation_creates_alert(self): + """Test that SLA violation creates an alert record.""" + from django.core.management import call_command + + # Create events in the previous hour (below 95% threshold) + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + for i in range(90): + ContractEventFactory( + contract=self.contract, + timestamp=hour_start + timedelta(minutes=i), + ) + + out = StringIO() + call_command("calculate_sla", stdout=out) + + sla = ContractCompletenessSLA.objects.filter(contract=self.contract).latest("hour_start") + self.assertTrue(sla.is_violated) + + alerts = SLAAlert.objects.filter(contract=self.contract, alert_type=SLAAlert.ALERT_TYPE_SLA_VIOLATION) + self.assertEqual(alerts.count(), 1) + self.assertEqual(alerts.first().sla_record, sla) + + def test_sla_recovery_creates_recovery_alert(self): + """Test that SLA recovery creates a recovery alert.""" + from django.core.management import call_command + + # First hour - violation + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) - timedelta(hours=2) + for i in range(90): + ContractEventFactory( + contract=self.contract, + timestamp=hour_start + timedelta(minutes=i), + ) + + # Second hour - recovery + next_hour = hour_start + timedelta(hours=1) + for i in range(98): + ContractEventFactory( + contract=self.contract, + timestamp=next_hour + timedelta(minutes=i), + ) + + out = StringIO() + call_command("calculate_sla", "--hours=2", stdout=out) + + # Check both SLA records + violations = ContractCompletenessSLA.objects.filter(contract=self.contract, is_violated=True) + recoveries = ContractCompletenessSLA.objects.filter(contract=self.contract, is_violated=False) + + self.assertTrue(violations.exists()) + self.assertTrue(recoveries.exists()) + + recovery_alerts = SLAAlert.objects.filter(contract=self.contract, alert_type=SLAAlert.ALERT_TYPE_RECOVERY) + self.assertTrue(recovery_alerts.exists()) + + def test_specific_contract_option(self): + """Test calculating SLA for a specific contract only.""" + from django.core.management import call_command + + # Create events for both contracts + contract2 = TrackedContractFactory(name="Contract2") + hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + + for i in range(95): + ContractEventFactory( + contract=self.contract, + timestamp=hour_start + timedelta(minutes=i), + ) + for i in range(95): + ContractEventFactory( + contract=contract2, + timestamp=hour_start + timedelta(minutes=i), + ) + + out = StringIO() + call_command("calculate_sla", "--contract-id", self.contract.contract_id, stdout=out) + + sla_count = ContractCompletenessSLA.objects.filter(contract=self.contract).count() + sla_count_2 = ContractCompletenessSLA.objects.filter(contract=contract2).count() + + self.assertEqual(sla_count, 1) + self.assertEqual(sla_count_2, 0) + + +class SLAMetricsAPITest(TestCase): + def setUp(self): + self.contract = TrackedContractFactory(name="SLAContract") + self.hour_start = timezone.now().replace(minute=0, second=0, microsecond=0) - timedelta(hours=1) + + def test_sla_metrics_endpoint(self): + """Test the SLA metrics API endpoint.""" + from django.test import Client + + # Create SLA records + ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=self.hour_start, + events_expected=100, + events_indexed=98, + sla_percentage=98.0, + is_violated=False, + ) + ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=self.hour_start - timedelta(hours=1), + events_expected=100, + events_indexed=92, + sla_percentage=92.0, + is_violated=True, + ) + + client = Client() + response = client.get("/api/ingest/admin/sla-metrics/") + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["contract"], self.contract.id) + self.assertEqual(data[0]["latest_sla"], 98.0) + self.assertEqual(data[0]["violations"], 1) + + @override_settings(DEBUG=True) + def test_sla_metrics_aggregates_avg(self): + """Test that SLA metrics aggregates average correctly.""" + from django.test import Client + + ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=self.hour_start, + events_expected=100, + events_indexed=96, + sla_percentage=96.0, + is_violated=False, + ) + ContractCompletenessSLA.objects.create( + contract=self.contract, + hour_start=self.hour_start - timedelta(hours=1), + events_expected=100, + events_indexed=94, + sla_percentage=94.0, + is_violated=False, + ) + + client = Client() + response = client.get("/api/ingest/admin/sla-metrics/") + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertAlmostEqual(data[0]["avg_sla"], 95.0, places=1) diff --git a/django-backend/soroscan/ingest/urls.py b/django-backend/soroscan/ingest/urls.py index 3188cf89e..8620b9c47 100644 --- a/django-backend/soroscan/ingest/urls.py +++ b/django-backend/soroscan/ingest/urls.py @@ -26,6 +26,7 @@ networks_view, record_event_view, restore_archived_events, + sla_metrics_view, transaction_events_view, vulnerability_impact_view, ) @@ -79,6 +80,7 @@ organization_cost_breakdown_view, name="admin-organization-costs", ), + path("admin/sla-metrics/", sla_metrics_view, name="admin-sla-metrics"), path("deletion-requests/", deletion_requests_view, name="deletion-requests"), path("compliance-export/", compliance_export_view, name="compliance-export"), path("networks/", networks_view, name="networks"), diff --git a/django-backend/soroscan/ingest/views.py b/django-backend/soroscan/ingest/views.py index 0c3ff282b..77ce334d7 100644 --- a/django-backend/soroscan/ingest/views.py +++ b/django-backend/soroscan/ingest/views.py @@ -31,6 +31,7 @@ APIKey, AdminAction, ArchivedEventBatch, + ContractCompletenessSLA, ContractEvent, ContractInvocation, ContractSource, @@ -1812,3 +1813,26 @@ def contract_identity_view(request): "network_passphrase": getattr(settings, "STELLAR_NETWORK_PASSPHRASE", ""), "rpc_url": getattr(settings, "SOROBAN_RPC_URL", ""), }) + + +# --------------------------------------------------------------------------- +# Issue #536: Event Completeness SLA Tracking +# --------------------------------------------------------------------------- + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def sla_metrics_view(request): + """Return SLA metrics for all contracts for the past 24 hours.""" + latest_sla = ( + ContractCompletenessSLA.objects + .values("contract") + .annotate( + latest_sla=Max("sla_percentage"), + latest_hour=Max("hour_start"), + avg_sla=Avg("sla_percentage"), + violations=Count("id", filter=Q(is_violated=True)), + ) + .order_by("-latest_sla") + ) + + return Response(list(latest_sla)) diff --git a/django-backend/soroscan/management/commands/add_user_to_org.py b/django-backend/soroscan/management/commands/add_user_to_org.py new file mode 100644 index 000000000..a1e6b5713 --- /dev/null +++ b/django-backend/soroscan/management/commands/add_user_to_org.py @@ -0,0 +1,77 @@ +""" +Management command: add_user_to_org + +Add a user to an organization with a specified role. + +Usage: + python manage.py add_user_to_org --organization_slug="my-org" --user_username="john" --role="admin" +""" +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +from soroscan.ingest.models import Organization, OrganizationMembership + + +class Command(BaseCommand): + help = "Add a user to an organization with a specified role." + + def add_arguments(self, parser): + parser.add_argument( + "--organization_slug", + required=True, + help="Organization slug", + ) + parser.add_argument( + "--user_username", + required=True, + help="Username of the user to add", + ) + parser.add_argument( + "--role", + choices=["owner", "admin", "member"], + default="member", + help="Role to assign (default: member)", + ) + + def handle(self, *args, **options): + organization_slug = options["organization_slug"] + user_username = options["user_username"] + role = options["role"] + + try: + organization = Organization.objects.get(slug=organization_slug) + except Organization.DoesNotExist: + self.stderr.write( + self.style.ERROR(f'Organization with slug "{organization_slug}" does not exist') + ) + return + + User = get_user_model() + try: + user = User.objects.get(username=user_username) + except User.DoesNotExist: + self.stderr.write( + self.style.ERROR(f'User with username "{user_username}" does not exist') + ) + return + + # Check if the user is already a member of the organization + if OrganizationMembership.objects.filter(organization=organization, user=user).exists(): + self.stderr.write( + self.style.ERROR( + f'User "{user_username}" is already a member of organization "{organization_slug}"' + ) + ) + return + + membership = OrganizationMembership.objects.create( + organization=organization, + user=user, + role=role, + ) + + self.stdout.write( + self.style.SUCCESS( + f'User "{user_username}" added to organization "{organization_slug}" with role "{role}".' + ) + ) \ No newline at end of file diff --git a/django-backend/soroscan/management/commands/create_organization.py b/django-backend/soroscan/management/commands/create_organization.py new file mode 100644 index 000000000..6e1814ca0 --- /dev/null +++ b/django-backend/soroscan/management/commands/create_organization.py @@ -0,0 +1,78 @@ +""" +Management command: create_organization + +Creates a new organization. + +Usage: + python manage.py create_organization --name="My Org" --owner_username="john" +""" +import json + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand +from django.utils.text import slugify + +from soroscan.ingest.models import Organization + + +class Command(BaseCommand): + help = "Create a new organization." + + def add_arguments(self, parser): + parser.add_argument( + "--name", + required=True, + help="Organization name", + ) + parser.add_argument( + "--owner_username", + required=True, + help="Username of the organization owner", + ) + parser.add_argument( + "--slug", + help="Organization slug (optional, generated from name if not provided)", + ) + parser.add_argument( + "--quota", + type=int, + default=0, + help="Monthly event quota (default: 0)", + ) + + def handle(self, *args, **options): + name = options["name"] + owner_username = options["owner_username"] + slug = options["slug"] + quota = options["quota"] + + User = get_user_model() + try: + owner = User.objects.get(username=owner_username) + except User.DoesNotExist: + self.stderr.write( + self.style.ERROR(f'User with username "{owner_username}" does not exist') + ) + return + + if not slug: + slug = slugify(name) or "organization" + # Ensure slug is unique + original_slug = slug + n = 1 + while Organization.objects.filter(slug=slug).exists(): + slug = f"{original_slug}-{n}" + n += 1 + + organization = Organization.objects.create( + name=name, + slug=slug, + owner=owner, + quota=quota, + ) + + self.stdout.write( + self.style.SUCCESS( + f'Organization "{organization.name}" (slug: {organization.slug}) created successfully.' + ) + ) \ No newline at end of file diff --git a/django-backend/soroscan/management/commands/generate_invoice.py b/django-backend/soroscan/management/commands/generate_invoice.py new file mode 100644 index 000000000..527146f22 --- /dev/null +++ b/django-backend/soroscan/management/commands/generate_invoice.py @@ -0,0 +1,135 @@ +""" +Management command: generate_invoice + +Generate an invoice for an organization. + +Usage: + python manage.py generate_invoice --organization_slug="my-org" --amount_usd="100.00" --invoice_number="INV001" --period_start="2026-01-01" --period_end="2026-01-31" --due_date="2026-02-15" +""" +import json +from datetime import datetime + +from django.core.management.base import BaseCommand, CommandError +from django.utils.dateparse import parse_date + +from soroscan.ingest.models import Invoice, Organization + + +class Command(BaseCommand): + help = "Generate an invoice for an organization." + + def add_arguments(self, parser): + parser.add_argument( + "--organization_slug", + required=True, + help="Organization slug", + ) + parser.add_argument( + "--amount_usd", + required=True, + type=float, + help="Invoice amount in USD", + ) + parser.add_argument( + "--invoice_number", + required=True, + help="Unique invoice number", + ) + parser.add_argument( + "--period_start", + required=True, + help="Start of billing period (YYYY-MM-DD)", + ) + parser.add_argument( + "--period_end", + required=True, + help="End of billing period (YYYY-MM-DD)", + ) + parser.add_argument( + "--due_date", + required=True, + help="Due date (YYYY-MM-DD)", + ) + parser.add_argument( + "--currency", + default="USD", + help="Currency code (default: USD)", + ) + parser.add_argument( + "--status", + choices=[choice[0] for choice in Invoice.STATUS_CHOICES], + default=Invoice.STATUS_DRAFT, + help="Invoice status (default: draft)", + ) + parser.add_argument( + "--line_items", + default="[]", + help='JSON string representing line items (default: [])', + ) + parser.add_argument( + "--notes", + default="", + help="Optional notes", + ) + + def handle(self, *args, **options): + organization_slug = options["organization_slug"] + amount_usd = options["amount_usd"] + invoice_number = options["invoice_number"] + period_start_str = options["period_start"] + period_end_str = options["period_end"] + due_date_str = options["due_date"] + currency = options["currency"] + status = options["status"] + line_items_str = options["line_items"] + notes = options["notes"] + + # Parse dates + period_start = parse_date(period_start_str) + period_end = parse_date(period_end_str) + due_date = parse_date(due_date_str) + + if period_start is None: + raise CommandError(f'Invalid date format for period_start: {period_start_str}. Use YYYY-MM-DD.') + if period_end is None: + raise CommandError(f'Invalid date format for period_end: {period_end_str}. Use YYYY-MM-DD.') + if due_date is None: + raise CommandError(f'Invalid date format for due_date: {due_date_str}. Use YYYY-MM-DD.') + + # Parse line_items + try: + line_items = json.loads(line_items_str) + if not isinstance(line_items, list): + raise ValueError("line_items must be a JSON list") + except json.JSONDecodeError: + raise CommandError("line_items must be a valid JSON string") + except ValueError as e: + raise CommandError(f"Invalid line_items: {e}") + + try: + organization = Organization.objects.get(slug=organization_slug) + except Organization.DoesNotExist: + raise CommandError(f'Organization with slug "{organization_slug}" does not exist') + + # Check if invoice_number is unique + if Invoice.objects.filter(invoice_number=invoice_number).exists(): + raise CommandError(f'Invoice with number "{invoice_number}" already exists.') + + invoice = Invoice.objects.create( + organization=organization, + amount_usd=amount_usd, + invoice_number=invoice_number, + currency=currency, + status=status, + period_start=period_start, + period_end=period_end, + due_date=due_date, + line_items=line_items, + notes=notes, + ) + + self.stdout.write( + self.style.SUCCESS( + f'Invoice "{invoice.invoice_number}" for organization "{organization.name}" created successfully.' + ) + ) \ No newline at end of file diff --git a/django-backend/soroscan/management/commands/reset_api_key.py b/django-backend/soroscan/management/commands/reset_api_key.py new file mode 100644 index 000000000..46d08a2e1 --- /dev/null +++ b/django-backend/soroscan/management/commands/reset_api_key.py @@ -0,0 +1,61 @@ +""" +Management command: reset_api_key + +Reset (rotate) an API key for a user. + +Usage: + python manage.py reset_api_key --user_username="john" --key_name="my-key" +""" +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +from soroscan.ingest.models import APIKey + + +class Command(BaseCommand): + help = "Reset (rotate) an API key for a user." + + def add_arguments(self, parser): + parser.add_argument( + "--user_username", + required=True, + help="Username of the user who owns the API key", + ) + parser.add_argument( + "--key_name", + required=True, + help="Name of the API key to reset", + ) + + def handle(self, *args, **options): + user_username = options["user_username"] + key_name = options["key_name"] + + User = get_user_model() + try: + user = User.objects.get(username=user_username) + except User.DoesNotExist: + self.stderr.write( + self.style.ERROR(f'User with username "{user_username}" does not exist') + ) + return + + try: + api_key = APIKey.objects.get(user=user, name=key_name) + except APIKey.DoesNotExist: + self.stderr.write( + self.style.ERROR( + f'APIKey with name "{key_name}" for user "{user_username}" does not exist' + ) + ) + return + + # Reset the key by setting it to empty string to trigger regeneration + api_key.key = "" + api_key.save() + + self.stdout.write( + self.style.SUCCESS( + f'APIKey "{api_key.name}" for user "{user_username}" has been reset. New key: {api_key.key}' + ) + ) \ No newline at end of file diff --git a/django-backend/test_migrate.db b/django-backend/test_migrate.db new file mode 100644 index 000000000..e69de29bb diff --git a/soroscan-frontend/app/contracts/[id]/page.tsx b/soroscan-frontend/app/contracts/[id]/page.tsx new file mode 100644 index 000000000..6eaddb712 --- /dev/null +++ b/soroscan-frontend/app/contracts/[id]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import { EventRateMeter } from "@/components/ingest/EventRateMeter"; + +export default function ContractDetailPage({ + params, +}: { + params: { id: string }; +}) { + const { id } = params; + + return ( +
+
+

+ Contract Details +

+ + {/* Additional contract details can be added here */} +
+

+ Contract ID: {id} +

+ {/* Placeholder for other contract information */} +
+
+
+ ); +} \ No newline at end of file diff --git a/soroscan-frontend/app/contracts/compare/page.tsx b/soroscan-frontend/app/contracts/compare/page.tsx new file mode 100644 index 000000000..ca55850c4 --- /dev/null +++ b/soroscan-frontend/app/contracts/compare/page.tsx @@ -0,0 +1,436 @@ +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Card } from "@/components/terminal/Card"; +import { Button } from "@/components/terminal/Button"; +import { + listContracts, + listWebhooks, +} from "@/components/ingest/contract-graphql"; +import { fetchTimeline } from "@/components/ingest/graphql"; +import type { Contract } from "@/components/ingest/contract-types"; +import type { TimelineBucketSize } from "@/components/ingest/types"; + +function DiffBadge({ a, b }: { a: number; b: number }) { + if (a > b) { + return A+; + } + if (a < b) { + return B+; + } + return EQ; +} + +function ActivityBarChart({ data }: { data: { label: string; value: number }[] }) { + const maxVal = Math.max(...data.map((d) => d.value), 1); + if (data.length === 0) { + return ( +
NO_ACTIVITY_DATA
+ ); + } + return ( +
+
+ {data.map((point, i) => { + const height = (point.value / maxVal) * 100; + return ( +
+
+
+ {point.value} +
+
+
+ {point.label} +
+
+ ); + })} +
+
+ ); +} + +function ActivityPatternCard({ + title, + timeline, + loading, +}: { + title: string; + timeline: { since: string; until: string; groups: { eventCount: number }[] } | null; + loading: boolean; +}) { + const data = React.useMemo(() => { + if (!timeline) return []; + return timeline.groups.map((g) => ({ + label: new Date(g.end).toLocaleDateString("en-GB", { day: "2-digit", month: "short" }), + value: g.eventCount, + })); + }, [timeline]); + + return ( +
+
[{title}]
+ {loading ? ( +
LOADING_TIMELINE...
+ ) : ( + + )} +
+ ); +} + +export default function ContractComparePage() { + const router = useRouter(); + const [contracts, setContracts] = React.useState([]); + const [contractAId, setContractAId] = React.useState(""); + const [contractBId, setContractBId] = React.useState(""); + const [loadingContracts, setLoadingContracts] = React.useState(true); + const [loadingA, setLoadingA] = React.useState(false); + const [loadingB, setLoadingB] = React.useState(false); + const [error, setError] = React.useState(null); + + const [webhooksA, setWebhooksA] = React.useState<{ id: string; contractId: string }[]>([]); + const [webhooksB, setWebhooksB] = React.useState<{ id: string; contractId: string }[]>([]); + const [timelineA, setTimelineA] = React.useState<{ since: string; until: string; groups: { eventCount: number }[] } | null>(null); + const [timelineB, setTimelineB] = React.useState<{ since: string; until: string; groups: { eventCount: number }[] } | null>(null); + + React.useEffect(() => { + const loadContracts = async () => { + try { + const data = await listContracts(); + setContracts(data); + if (data.length >= 2) { + setContractAId(data[0].id); + setContractBId(data[1].id); + } else if (data.length === 1) { + setContractAId(data[0].id); + setContractBId(data[0].id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load contracts"); + } finally { + setLoadingContracts(false); + } + }; + loadContracts(); + }, []); + + React.useEffect(() => { + if (!contractAId) return; + const abortController = new AbortController(); + const loadA = async () => { + setLoadingA(true); + try { + const [timeline, allWebhooks] = await Promise.all([ + fetchTimeline({ + contractId: contractAId, + bucketSize: "ONE_DAY" as TimelineBucketSize, + eventTypes: null, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + includeEvents: false, + limitGroups: 30, + }), + listWebhooks(), + ]); + if (!abortController.signal.aborted) { + setTimelineA({ + since: timeline.since, + until: timeline.until, + groups: timeline.groups, + }); + setWebhooksA(allWebhooks.filter((w) => w.contractId === contractAId)); + } + } catch (err) { + if (!abortController.signal.aborted) { + console.error("Failed to load Contract A data", err); + } + } finally { + if (!abortController.signal.aborted) { + setLoadingA(false); + } + } + }; + loadA(); + return () => abortController.abort(); + }, [contractAId]); + + React.useEffect(() => { + if (!contractBId) return; + const abortController = new AbortController(); + const loadB = async () => { + setLoadingB(true); + try { + const [timeline, allWebhooks] = await Promise.all([ + fetchTimeline({ + contractId: contractBId, + bucketSize: "ONE_DAY" as TimelineBucketSize, + eventTypes: null, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + includeEvents: false, + limitGroups: 30, + }), + listWebhooks(), + ]); + if (!abortController.signal.aborted) { + setTimelineB({ + since: timeline.since, + until: timeline.until, + groups: timeline.groups, + }); + setWebhooksB(allWebhooks.filter((w) => w.contractId === contractBId)); + } + } catch (err) { + if (!abortController.signal.aborted) { + console.error("Failed to load Contract B data", err); + } + } finally { + if (!abortController.signal.aborted) { + setLoadingB(false); + } + } + }; + loadB(); + return () => abortController.abort(); + }, [contractBId]); + + const contractA = contracts.find((c) => c.id === contractAId); + const contractB = contracts.find((c) => c.id === contractBId); + + const swapContracts = () => { + const prevA = contractAId; + setContractAId(contractBId); + setContractBId(prevA); + }; + + if (loadingContracts) { + return ( +
+
LOADING_CONTRACTS...
+
+ ); + } + + if (contracts.length < 2) { + return ( +
+ +
+ Need at least 2 tracked contracts to use comparison view.{" "} + +
+
+
+ ); + } + + const countA = contractA?.eventCount ?? 0; + const countB = contractB?.eventCount ?? 0; + const subsA = webhooksA.length; + const subsB = webhooksB.length; + const totalA = timelineA ? timelineA.groups.reduce((sum, g) => sum + g.eventCount, 0) : 0; + const totalB = timelineB ? timelineB.groups.reduce((sum, g) => sum + g.eventCount, 0) : 0; + + const diffRows: { label: string; a: number | string; b: number | string }[] = [ + { label: "EVENT_COUNT", a: countA, b: countB }, + { label: "WEBHOOK_SUBSCRIPTIONS", a: subsA, b: subsB }, + { label: "EVENTS_LAST_30_DAYS", a: totalA, b: totalB }, + ]; + + return ( +
+
+
+
+

CONTRACT_COMPARISON

+

+ SIDE-BY-SIDE_ANALYSIS / EVENT_COUNTS / WEBHOOK_ACTIVITY +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ A +

{contractA?.name ?? "--"}

+ {contractA?.contractId.slice(0, 8)}... +
+ + +
+ {countA.toLocaleString()} + +
+
+ + +
+ {loadingA ? ( +
LOADING_EVENTS...
+ ) : eventsA.length === 0 ? ( +
NO_EVENTS_FOUND
+ ) : ( + eventsA.slice(0, 5).map((ev) => ( +
+
+ {ev.eventType} + + {new Date(ev.timestamp).toLocaleString("en-GB", { dateStyle: "short", timeStyle: "short" })} + +
+
{ev.txHash}
+
+ )) + )} +
+
+ + +
+ {subsA} + +
+
+ + +
+ +
+
+ B +

{contractB?.name ?? "--"}

+ {contractB?.contractId.slice(0, 8)}... +
+ + +
+ {countB.toLocaleString()} + +
+
+ + +
+ {loadingB ? ( +
LOADING_EVENTS...
+ ) : eventsB.length === 0 ? ( +
NO_EVENTS_FOUND
+ ) : ( + eventsB.slice(0, 5).map((ev) => ( +
+
+ {ev.eventType} + + {new Date(ev.timestamp).toLocaleString("en-GB", { dateStyle: "short", timeStyle: "short" })} + +
+
{ev.txHash}
+
+ )) + )} +
+
+ + +
+ {subsB} + +
+
+ + +
+
+ + +
+ + + + + + + + + + + {diffRows.map((row) => { + const aNum = typeof row.a === "number" ? row.a : 0; + const bNum = typeof row.b === "number" ? row.b : 0; + const delta = aNum - bNum; + const deltaLabel = + typeof row.a === "number" && typeof row.b === "number" + ? `${delta >= 0 ? "+" : ""}${delta.toLocaleString()}` + : "—"; + const deltaColor = + delta > 0 + ? "text-terminal-green" + : delta < 0 + ? "text-terminal-danger" + : "text-terminal-gray"; + return ( + + + + + + + ); + })} + +
METRICABDELTA
{row.label}{typeof row.a === "number" ? row.a.toLocaleString() : row.a}{typeof row.b === "number" ? row.b.toLocaleString() : row.b}{deltaLabel}
+
+
+
+
+ ); +} diff --git a/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx b/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx index 26b231704..bb92b0b15 100644 --- a/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx +++ b/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx @@ -344,7 +344,8 @@ export function EventExplorerDashboard() { } if (!next.length) { - const { [eventId]: _, ...rest } = prev; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [eventId]: _, ...rest } = prev; return rest; } diff --git a/soroscan-frontend/app/webhooks/[id]/page.tsx b/soroscan-frontend/app/webhooks/[id]/page.tsx index 8135c7a28..62376049a 100644 --- a/soroscan-frontend/app/webhooks/[id]/page.tsx +++ b/soroscan-frontend/app/webhooks/[id]/page.tsx @@ -56,13 +56,13 @@ export default function WebhookDetailPage() { const [timeoutError, setTimeoutError] = React.useState(null) const [saveMessage, setSaveMessage] = React.useState(null) - React.useEffect(() => { - if (webhook) { - setTimeoutInput(String(webhook.timeoutSeconds ?? 30)) - setTimeoutError(null) - setSaveMessage(null) - } - }, [webhook?.id]) + React.useEffect(() => { + if (webhook) { + setTimeoutInput(String(webhook.timeoutSeconds ?? 30)) + setTimeoutError(null) + setSaveMessage(null) + } + }, [webhook]) const parseTimeout = (value: string) => { const parsed = Number(value) diff --git a/soroscan-frontend/components/__tests__/EventRateMeter.test.tsx b/soroscan-frontend/components/__tests__/EventRateMeter.test.tsx new file mode 100644 index 000000000..db0cabb22 --- /dev/null +++ b/soroscan-frontend/components/__tests__/EventRateMeter.test.tsx @@ -0,0 +1,105 @@ +import { render, screen } from "@testing-library/react"; +import { useQuery } from "@apollo/client"; + +import { EventRateMeter } from "../ingest/EventRateMeter"; + +jest.mock("@apollo/client", () => ({ + ...jest.requireActual("@apollo/client"), + useQuery: jest.fn(), +})); + +describe("EventRateMeter", () => { + const mockContractId = "test-contract-id"; + const mockedUseQuery = useQuery as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders loading state when data is loading", () => { + mockedUseQuery.mockReturnValue({ + loading: true, + data: undefined, + error: undefined, + }); + + render(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("renders error message when there's an error", () => { + mockedUseQuery.mockReturnValue({ + loading: false, + data: undefined, + error: new Error("Failed to fetch"), + }); + + render(); + expect(screen.getByText(/error loading data/i)).toBeInTheDocument(); + }); + + it("renders contract not found when no contract data", () => { + mockedUseQuery.mockReturnValue({ + loading: false, + data: { contract: null }, + error: undefined, + }); + + render(); + expect(screen.getByText(/contract not found/i)).toBeInTheDocument(); + }); + + it("displays the correct rate and color when rate is low", () => { + mockedUseQuery.mockReturnValue({ + loading: false, + data: { + contract: { + id: mockContractId, + maxEventsPerMinute: 100, + events: { totalCount: 10 }, + recentEvents: { + edges: [ + { node: { timestamp: "2026-05-31T21:40:00Z" } }, + { node: { timestamp: "2026-05-31T21:41:00Z" } }, + { node: { timestamp: "2026-05-31T21:42:00Z" } }, + ], + }, + }, + }, + error: undefined, + }); + + render(); + + const rateText = screen.getByText("1"); + expect(rateText).toBeInTheDocument(); + expect(rateText).toHaveClass("text-terminal-green"); + expect(screen.getByText(/events\/min/)).toBeInTheDocument(); + }); + + it("displays yellow color when rate is approaching limit", () => { + const baseTime = new Date("2026-05-31T21:40:00Z").getTime(); + const edges = Array.from({ length: 10 }, (_, index) => ({ + node: { timestamp: new Date(baseTime + index * 750).toISOString() }, + })); + + mockedUseQuery.mockReturnValue({ + loading: false, + data: { + contract: { + id: mockContractId, + maxEventsPerMinute: 100, + events: { totalCount: 10 }, + recentEvents: { edges }, + }, + }, + error: undefined, + }); + + render(); + + const rateText = screen.getByText("80"); + expect(rateText).toBeInTheDocument(); + expect(rateText).toHaveClass("text-terminal-yellow"); + }); +}); diff --git a/soroscan-frontend/components/ingest/EventRateMeter.tsx b/soroscan-frontend/components/ingest/EventRateMeter.tsx new file mode 100644 index 000000000..dd1e34bb4 --- /dev/null +++ b/soroscan-frontend/components/ingest/EventRateMeter.tsx @@ -0,0 +1,125 @@ +"use client"; + +import * as React from "react"; +import { useQuery } from "@apollo/client"; +import { GET_CONTRACT_RATE_QUERY } from "./contract-graphql"; + +interface RecentEventEdge { + node: { + timestamp: string; + } +} + +interface ContractRateData { + contract: { + id: string; + maxEventsPerMinute: number; + events: { totalCount: number }; + recentEvents: { + edges: RecentEventEdge[]; + }; + }; +} + +interface EventRateMeterProps { + contractId: string; + maxRate?: number; + updateInterval?: number; +} + +export function EventRateMeter({ + contractId, + maxRate, + updateInterval = 10000, +}: EventRateMeterProps) { + const { data, loading, error } = useQuery(GET_CONTRACT_RATE_QUERY, { + variables: { contractId }, + pollInterval: updateInterval, + }); + + if (loading) return
Loading...
; + if (error) return
Error loading data
; + + const contract = data?.contract; + if (!contract) return
Contract not found
; + + const maxEventsPerMinute = maxRate ?? contract.maxEventsPerMinute; + const recentEvents = contract.recentEvents?.edges ?? []; + + // Calculate events per minute from recent event timestamps + const rate = calculateRatePerMinute(recentEvents); + + // Determine color based on thresholds + const getRateColor = (rate: number, max: number): string => { + if (rate >= max) return "text-terminal-danger"; + if (rate >= max * 0.8) return "text-terminal-yellow"; + return "text-terminal-green"; + }; + + const rateColor = getRateColor(rate, maxEventsPerMinute); + + // Calculate percentage for the gauge (capped at 100%) + const percentage = Math.min((rate / maxEventsPerMinute) * 100, 100); + + return ( +
+
+ {/* SVG Gauge */} + + {/* Background arc */} + + {/* Progressive arc */} + + +
+
+
+ {Math.round(rate)} +
+
+ events/min +
+
+
+ ); +} + +/** + * Calculate events per minute from a list of event edges with timestamps. + * @param edges Array of event edges from GraphQL + * @returns Events per minute (float) + */ +function calculateRatePerMinute(edges: RecentEventEdge[]): number { + if (edges.length < 2) return 0; + + const timestamps = edges + .map(edge => edge.node.timestamp) + .map(timestamp => new Date(timestamp).getTime()) + .sort((a, b) => a - b); // ascending + + const oldest = timestamps[0]; + const newest = timestamps[timestamps.length - 1]; + const timeSpanMs = newest - oldest; + + if (timeSpanMs === 0) { + // All events at the same time, return a high rate based on count + return edges.length * 60; // assuming they happened in the same second + } + + // We have (n-1) intervals between n events + const rate = ((edges.length - 1) / timeSpanMs) * 60000; + return rate; +} diff --git a/soroscan-frontend/components/ingest/contract-graphql.ts b/soroscan-frontend/components/ingest/contract-graphql.ts index e6c6de96d..13d9dde1c 100644 --- a/soroscan-frontend/components/ingest/contract-graphql.ts +++ b/soroscan-frontend/components/ingest/contract-graphql.ts @@ -1,6 +1,16 @@ +import { gql } from "@apollo/client"; + import { graphqlRequest } from "./graphql"; import type { Contract, ContractFormData, BackfillTask } from "./contract-types"; +interface WebhookSubscriptionSummary { + id: string; + contractId: string; + eventType: string; + targetUrl: string; + isActive: boolean; +} + export const LIST_CONTRACTS_QUERY = ` query ListContracts { contracts { @@ -84,6 +94,25 @@ export const TRIGGER_BACKFILL_MUTATION = ` } `; +export const GET_CONTRACT_RATE_QUERY = gql` + query GetContractRate($contractId: String!) { + contract(id: $contractId) { + id + maxEventsPerMinute + events { + totalCount + } + recentEvents: events(first: 10) { + edges { + node { + timestamp + } + } + } + } + } +`; + export async function listContracts(): Promise { const data = await graphqlRequest<{ contracts: Contract[] }, Record>( LIST_CONTRACTS_QUERY, @@ -134,3 +163,23 @@ export async function triggerBackfill(contractId: string): Promise >(TRIGGER_BACKFILL_MUTATION, { contractId }); return data.triggerBackfill; } + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +export async function listWebhooks(): Promise { + const response = await fetch(`${API_BASE}/api/webhooks/`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`Failed to load webhooks: ${response.status}`); + } + const data = await response.json(); + return (data.results ?? data).map((wh: Record) => ({ + id: String(wh.id), + contractId: String(wh.contract_id ?? ""), + eventType: String(wh.event_type ?? ""), + targetUrl: String(wh.target_url ?? ""), + isActive: Boolean(wh.is_active), + })); +}