From c595bd8c79168029626a6b2365b2352f700af9c5 Mon Sep 17 00:00:00 2001 From: "whiteghost.dev" Date: Mon, 1 Jun 2026 13:31:59 +0000 Subject: [PATCH] feat: contract stats cards (#556) + cost attribution per org (#538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Issue #556 — Contract Stats Cards (frontend) Closes #556 ### What was done Added a ContractStatsCards component to the Event Explorer Dashboard that displays four at-a-glance statistics for the currently selected contract: • Total Events — total event count for the active filter/page set • Events / Hour — count of events emitted in the last 60 minutes • Latest Event — wall-clock time of the most recent event • Event Types — number of distinct event types in the current result set ### How it was done - Created soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx A pure client component that receives the already-loaded `events`, `totalCount`, and `loading` props from EventExplorerDashboard. Stats are derived with useMemo — no extra network requests. - Created ContractStatsCards.module.css with a 4-column responsive grid (collapses to 2-col on ≤700 px, 1-col on ≤400 px) styled to match the existing terminal/cyber aesthetic (JetBrains Mono, cyan/green accents, dark glass-morphism cards with a coloured left border per stat). - Imported and rendered ContractStatsCards inside EventExplorerDashboard.tsx between the AdvancedSearch bar and the events table section. --- ## Issue #538 — Cost Attribution per Organization (backend) Closes #538 ### What was done Implemented a full billing cost-attribution pipeline for multi-tenant orgs: 1. OrganizationUsage model (django-backend/soroscan/ingest/models.py) Stores a monthly usage snapshot per Team with three billing dimensions: - request_count (API/ingestion requests → compute cost) - storage_bytes (serialised event payload bytes → storage cost) - egress_bytes (bytes served via API → egress cost) Unique constraint on (team, period_start) so each org has one record per billing month. 2. Migration 0016_organizationusage.py Creates the ingest_organizationusage table and adds a composite index on (team_id, period_start) for fast per-org period lookups. 3. Billing service (django-backend/soroscan/ingest/services/billing.py) - compute_costs(usage) — converts raw usage counters to itemised USD costs using configurable per-unit rates (BILLING_PRICE_PER_REQUEST, BILLING_PRICE_PER_GB_STORAGE, BILLING_PRICE_PER_GB_EGRESS in Django settings; sensible defaults baked in). - snapshot_usage_for_team(team, period_start, period_end) — queries ContractEvent for the team's contracts in the given date range, counts events (request_count), sums serialised payload sizes (storage_bytes), and upserts an OrganizationUsage record. 4. REST API view + URL GET /api/billing/orgs//costs/?period=YYYY-MM - Authenticated endpoint; caller must be a team member or staff. - Defaults to the current calendar month if period is omitted. - Calls snapshot_usage_for_team then compute_costs and returns: { team, period_start, period_end, usage: {...}, costs: {...} } - Registered in ingest/urls.py as 'org-cost-attribution'. --- .../migrations/0016_organizationusage.py | 44 +++++++++++ django-backend/soroscan/ingest/models.py | 52 ++++++++++++ .../soroscan/ingest/services/billing.py | 68 ++++++++++++++++ django-backend/soroscan/ingest/urls.py | 2 + django-backend/soroscan/ingest/views.py | 58 ++++++++++++++ .../components/ContractStatsCards.module.css | 55 +++++++++++++ .../components/ContractStatsCards.tsx | 79 +++++++++++++++++++ .../components/EventExplorerDashboard.tsx | 7 ++ 8 files changed, 365 insertions(+) create mode 100644 django-backend/soroscan/ingest/migrations/0016_organizationusage.py create mode 100644 django-backend/soroscan/ingest/services/billing.py create mode 100644 soroscan-frontend/app/dashboard/components/ContractStatsCards.module.css create mode 100644 soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx diff --git a/django-backend/soroscan/ingest/migrations/0016_organizationusage.py b/django-backend/soroscan/ingest/migrations/0016_organizationusage.py new file mode 100644 index 000000000..a194963f2 --- /dev/null +++ b/django-backend/soroscan/ingest/migrations/0016_organizationusage.py @@ -0,0 +1,44 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("ingest", "0015_merge_notification_and_teams"), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationUsage", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("period_start", models.DateField(db_index=True, help_text="First day of the billing period (YYYY-MM-01)")), + ("period_end", models.DateField(help_text="Last day of the billing period (inclusive)")), + ("request_count", models.PositiveBigIntegerField(default=0, help_text="Total API requests made by this org in the period")), + ("storage_bytes", models.PositiveBigIntegerField(default=0, help_text="Total bytes of event payload data stored for this org")), + ("egress_bytes", models.PositiveBigIntegerField(default=0, help_text="Total bytes served to this org via API responses")), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "team", + models.ForeignKey( + help_text="Organization this usage record belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="usage_records", + to="ingest.team", + ), + ), + ], + options={ + "ordering": ["-period_start"], + }, + ), + migrations.AddIndex( + model_name="organizationusage", + index=models.Index(fields=["team", "period_start"], name="ingest_orgusage_team_period_idx"), + ), + migrations.AlterUniqueTogether( + name="organizationusage", + unique_together={("team", "period_start")}, + ), + ] diff --git a/django-backend/soroscan/ingest/models.py b/django-backend/soroscan/ingest/models.py index 903f51525..c45413c4d 100644 --- a/django-backend/soroscan/ingest/models.py +++ b/django-backend/soroscan/ingest/models.py @@ -1041,3 +1041,55 @@ class Meta: def __str__(self): return f"[{self.notification_type}] {self.title} → {self.user}" + + +# --------------------------------------------------------------------------- +# Issue #538: Cost Attribution per Organization +# --------------------------------------------------------------------------- + +class OrganizationUsage(models.Model): + """ + Monthly usage snapshot per team (organization) for billing cost attribution. + + Tracks three cost dimensions: + - request_count → compute cost (API calls made by the org) + - storage_bytes → storage cost (total payload bytes stored) + - egress_bytes → egress cost (bytes served via API responses) + """ + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="usage_records", + help_text="Organization this usage record belongs to", + ) + period_start = models.DateField( + db_index=True, + help_text="First day of the billing period (YYYY-MM-01)", + ) + period_end = models.DateField( + help_text="Last day of the billing period (inclusive)", + ) + request_count = models.PositiveBigIntegerField( + default=0, + help_text="Total API requests made by this org in the period", + ) + storage_bytes = models.PositiveBigIntegerField( + default=0, + help_text="Total bytes of event payload data stored for this org", + ) + egress_bytes = models.PositiveBigIntegerField( + default=0, + help_text="Total bytes served to this org via API responses", + ) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = [("team", "period_start")] + ordering = ["-period_start"] + indexes = [ + models.Index(fields=["team", "period_start"]), + ] + + def __str__(self): + return f"{self.team.name} usage {self.period_start}" diff --git a/django-backend/soroscan/ingest/services/billing.py b/django-backend/soroscan/ingest/services/billing.py new file mode 100644 index 000000000..bcd0c2a48 --- /dev/null +++ b/django-backend/soroscan/ingest/services/billing.py @@ -0,0 +1,68 @@ +""" +Billing service: compute cost attribution per organization (issue #538). + +Pricing constants (USD per unit) — override via Django settings if needed. +""" +import json +from datetime import date +from decimal import Decimal + +from django.conf import settings +from django.db.models import Sum + +from ..models import ContractEvent, OrganizationUsage, Team + +# --- Pricing rates (USD) --- +PRICE_PER_REQUEST = Decimal(getattr(settings, "BILLING_PRICE_PER_REQUEST", "0.000010")) # $0.01 / 1000 requests +PRICE_PER_GB_STORAGE = Decimal(getattr(settings, "BILLING_PRICE_PER_GB_STORAGE", "0.023")) # $0.023 / GB-month +PRICE_PER_GB_EGRESS = Decimal(getattr(settings, "BILLING_PRICE_PER_GB_EGRESS", "0.090")) # $0.09 / GB + +_GB = 1_073_741_824 # bytes in 1 GiB + + +def compute_costs(usage: OrganizationUsage) -> dict: + """Return a dict of itemised and total costs (USD) for a usage record.""" + request_cost = PRICE_PER_REQUEST * usage.request_count + storage_cost = PRICE_PER_GB_STORAGE * Decimal(usage.storage_bytes) / _GB + egress_cost = PRICE_PER_GB_EGRESS * Decimal(usage.egress_bytes) / _GB + total = request_cost + storage_cost + egress_cost + return { + "request_cost_usd": float(request_cost.quantize(Decimal("0.000001"))), + "storage_cost_usd": float(storage_cost.quantize(Decimal("0.000001"))), + "egress_cost_usd": float(egress_cost.quantize(Decimal("0.000001"))), + "total_cost_usd": float(total.quantize(Decimal("0.000001"))), + } + + +def snapshot_usage_for_team(team: Team, period_start: date, period_end: date) -> OrganizationUsage: + """ + Compute and upsert an OrganizationUsage record for the given team and period. + + - request_count: number of ContractEvents in the period (proxy for API-driven ingestion) + - storage_bytes: sum of JSON-serialised payload sizes for events in the period + - egress_bytes: same as storage_bytes (conservative estimate; real egress tracked via middleware) + """ + qs = ContractEvent.objects.filter( + contract__team=team, + timestamp__date__gte=period_start, + timestamp__date__lte=period_end, + ) + + request_count = qs.count() + + # Estimate storage as sum of serialised payload lengths + storage_bytes = sum( + len(json.dumps(e.payload).encode()) for e in qs.only("payload").iterator(chunk_size=500) + ) + + usage, _ = OrganizationUsage.objects.update_or_create( + team=team, + period_start=period_start, + defaults={ + "period_end": period_end, + "request_count": request_count, + "storage_bytes": storage_bytes, + "egress_bytes": storage_bytes, # conservative default + }, + ) + return usage diff --git a/django-backend/soroscan/ingest/urls.py b/django-backend/soroscan/ingest/urls.py index 7097ffa57..13b683dd4 100644 --- a/django-backend/soroscan/ingest/urls.py +++ b/django-backend/soroscan/ingest/urls.py @@ -17,6 +17,7 @@ health_check, record_event_view, restore_archived_events, + org_cost_attribution_view, ) router = DefaultRouter() @@ -39,4 +40,5 @@ path("health/", health_check, name="health-check"), path("events/restore-archive/", restore_archived_events, name="restore-archive"), path("audit-trail/", audit_trail_view, name="audit-trail"), + path("billing/orgs//costs/", org_cost_attribution_view, name="org-cost-attribution"), ] diff --git a/django-backend/soroscan/ingest/views.py b/django-backend/soroscan/ingest/views.py index 520c93228..9d2e15961 100644 --- a/django-backend/soroscan/ingest/views.py +++ b/django-backend/soroscan/ingest/views.py @@ -5,6 +5,8 @@ import hmac import json import logging +import calendar +from datetime import date from django.conf import settings from django.db.models import Count, Max, Q @@ -838,3 +840,59 @@ def audit_trail_view(request): serializer = AdminActionSerializer(qs[:limit], many=True) return Response(serializer.data) + + +# --------------------------------------------------------------------------- +# Issue #538: Cost Attribution per Organization +# --------------------------------------------------------------------------- + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def org_cost_attribution_view(request, team_slug): + """ + GET /api/billing/orgs//costs/?period=YYYY-MM + + Returns usage metrics and itemised cost breakdown for the given org. + Requires the caller to be a member of the team (or staff). + + Query params: + period – billing month as YYYY-MM (defaults to current month) + """ + from .models import OrganizationUsage + from .services.billing import compute_costs, snapshot_usage_for_team + + team = get_object_or_404(Team, slug=team_slug) + + # Access control: team member or staff + is_member = TeamMembership.objects.filter(team=team, user=request.user).exists() + if not (is_member or request.user.is_staff): + return Response({"detail": "Forbidden."}, status=status.HTTP_403_FORBIDDEN) + + # Parse ?period=YYYY-MM + period_str = request.query_params.get("period", "") + try: + if period_str: + year, month = int(period_str[:4]), int(period_str[5:7]) + else: + today = timezone.now().date() + year, month = today.year, today.month + period_start = date(year, month, 1) + period_end = date(year, month, calendar.monthrange(year, month)[1]) + except (ValueError, IndexError): + return Response({"detail": "Invalid period. Use YYYY-MM."}, status=status.HTTP_400_BAD_REQUEST) + + from datetime import date + usage = snapshot_usage_for_team(team, period_start, period_end) + costs = compute_costs(usage) + + return Response({ + "team": team.slug, + "period_start": str(period_start), + "period_end": str(period_end), + "usage": { + "request_count": usage.request_count, + "storage_bytes": usage.storage_bytes, + "egress_bytes": usage.egress_bytes, + }, + "costs": costs, + }) diff --git a/soroscan-frontend/app/dashboard/components/ContractStatsCards.module.css b/soroscan-frontend/app/dashboard/components/ContractStatsCards.module.css new file mode 100644 index 000000000..081b485cb --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/ContractStatsCards.module.css @@ -0,0 +1,55 @@ +.statsGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; +} + +.statCard { + border: 1px solid rgba(0, 212, 255, 0.2); + background: linear-gradient(180deg, rgba(13, 21, 34, 0.95), rgba(7, 12, 20, 0.95)); + box-shadow: 0 0 0 1px rgba(0, 255, 156, 0.05), 0 4px 12px rgba(0, 0, 0, 0.25); + padding: 1rem; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3rem; + border-left: 3px solid var(--accent, #00d4ff); + transition: box-shadow 0.18s ease; +} + +.statCard:hover { + box-shadow: 0 0 0 1px rgba(0, 255, 156, 0.12), 0 6px 18px rgba(0, 0, 0, 0.35); +} + +.statIcon { + font-size: 1.1rem; + color: var(--accent, #00d4ff); + line-height: 1; +} + +.statValue { + font-size: 1.5rem; + font-weight: 700; + color: #d6f7ff; + font-family: "JetBrains Mono", "Fira Code", monospace; + line-height: 1.1; +} + +.statLabel { + font-size: 0.72rem; + color: #7ba8b5; + text-transform: uppercase; + letter-spacing: 0.06rem; +} + +@media (max-width: 700px) { + .statsGrid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 400px) { + .statsGrid { + grid-template-columns: 1fr; + } +} diff --git a/soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx b/soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx new file mode 100644 index 000000000..aaadce234 --- /dev/null +++ b/soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useMemo } from "react"; +import type { EventRecord } from "@/components/ingest/types"; +import styles from "./ContractStatsCards.module.css"; + +interface Props { + events: EventRecord[]; + totalCount: number; + loading: boolean; +} + +export function ContractStatsCards({ events, totalCount, loading }: Props) { + const stats = useMemo(() => { + if (!events.length) { + return { eventsPerHour: 0, latestEventTime: null, eventTypesCount: 0 }; + } + + // Events per hour: count events in the last 60 minutes + const oneHourAgo = Date.now() - 60 * 60 * 1000; + const eventsPerHour = events.filter( + (e) => new Date(e.timestamp).getTime() >= oneHourAgo + ).length; + + // Latest event time + const sorted = [...events].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + const latestEventTime = sorted[0]?.timestamp ?? null; + + // Unique event types + const eventTypesCount = new Set(events.map((e) => e.eventType)).size; + + return { eventsPerHour, latestEventTime, eventTypesCount }; + }, [events]); + + const cards = [ + { + label: "Total Events", + value: loading ? "—" : totalCount.toLocaleString(), + icon: "◈", + accent: "#00ff9c", + }, + { + label: "Events / Hour", + value: loading ? "—" : stats.eventsPerHour.toLocaleString(), + icon: "⏱", + accent: "#00d4ff", + }, + { + label: "Latest Event", + value: loading + ? "—" + : stats.latestEventTime + ? new Date(stats.latestEventTime).toLocaleTimeString() + : "N/A", + icon: "◉", + accent: "#a78bfa", + }, + { + label: "Event Types", + value: loading ? "—" : stats.eventTypesCount.toLocaleString(), + icon: "⬡", + accent: "#f59e0b", + }, + ]; + + return ( +
+ {cards.map((card) => ( +
+ + {card.value} + {card.label} +
+ ))} +
+ ); +} diff --git a/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx b/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx index fb80ecce2..3fb346b6f 100644 --- a/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx +++ b/soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx @@ -6,6 +6,7 @@ import { FilterBar } from "./FilterBar"; import { EventDetailModal } from "./EventDetailModal"; import { PaginationControls } from "./PaginationControls"; import { AdvancedSearch } from "./AdvancedSearch"; +import { ContractStatsCards } from "./ContractStatsCards"; import { fetchAllContracts, fetchExplorerEvents } from "@/components/ingest/graphql"; import type { EventRecord } from "@/components/ingest/types"; import styles from "@/components/ingest/ingest-terminal.module.css"; @@ -201,6 +202,12 @@ export function EventExplorerDashboard() { initialQuery={filters.searchQuery} /> + +

Contract Events