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
Original file line number Diff line number Diff line change
@@ -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")},
),
]
68 changes: 68 additions & 0 deletions django-backend/soroscan/ingest/services/billing.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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;
}
}
79 changes: 79 additions & 0 deletions soroscan-frontend/app/dashboard/components/ContractStatsCards.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles.statsGrid} aria-label="Contract statistics">
{cards.map((card) => (
<div key={card.label} className={styles.statCard} style={{ "--accent": card.accent } as React.CSSProperties}>
<span className={styles.statIcon} aria-hidden="true">{card.icon}</span>
<span className={styles.statValue}>{card.value}</span>
<span className={styles.statLabel}>{card.label}</span>
</div>
))}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
import { useContractEventSubscription } from "@/src/hooks/useContractEventSubscription";
import { SubscriptionStatusBadge } from "@/components/ui/SubscriptionStatusBadge";

const PAGE_SIZE_STORAGE_KEY = "soroscan:page-size";

Check warning on line 19 in soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

'PAGE_SIZE_STORAGE_KEY' is assigned a value but never used
const DEFAULT_PAGE_SIZE = 25;

Check warning on line 20 in soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

'DEFAULT_PAGE_SIZE' is assigned a value but never used

interface Filters {
contractId: string;
Expand Down Expand Up @@ -222,7 +222,7 @@
};

loadEvents();
}, [filters.contractId, filters.eventType, filters.since, filters.until, currentPage, pageSize]);

Check warning on line 225 in soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

React Hook useEffect has an unnecessary dependency: 'pageSize'. Either exclude it or remove the dependency array. Outer scope values like 'pageSize' aren't valid dependencies because mutating them doesn't re-render the component

// Subscribe to real-time events
const { events: realTimeEvents, connectionState } = useContractEventSubscription({
Expand Down Expand Up @@ -290,7 +290,7 @@
});
}
}
}, [realTimeEvents, isPaused]);

Check warning on line 293 in soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

React Hook useEffect has missing dependencies: 'currentPage', 'filters.contractId', 'filters.eventType', 'filters.searchQuery', 'filters.since', 'filters.until', and 'showToast'. Either include them or remove the dependency array

// Apply search filter client-side
useEffect(() => {
Expand Down Expand Up @@ -345,7 +345,7 @@
}

if (!next.length) {
const { [eventId]: _, ...rest } = prev;

Check warning on line 348 in soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

'_' is assigned a value but never used
return rest;
}

Expand Down Expand Up @@ -435,7 +435,7 @@
const handlePageSizeChange = useCallback((newSize: number) => {
setPageSize(newSize);
setCurrentPage(1);
}, [setPageSize]);

Check warning on line 438 in soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

React Hook useCallback has an unnecessary dependency: 'setPageSize'. Either exclude it or remove the dependency array. Outer scope values like 'setPageSize' aren't valid dependencies because mutating them doesn't re-render the component

const startIndex = (currentPage - 1) * pageSize + 1;
const endIndex = startIndex + filteredEvents.length - 1;
Expand Down Expand Up @@ -467,6 +467,12 @@
initialQuery={filters.searchQuery}
/>

<ContractStatsCards

Check failure on line 470 in soroscan-frontend/app/dashboard/components/EventExplorerDashboard.tsx

View workflow job for this annotation

GitHub Actions / lint-and-test

'ContractStatsCards' is not defined
events={filteredEvents}
totalCount={totalCount}
loading={loading}
/>

<section className={styles.timelinePanel} aria-label="Events table">
<div className={styles.panelHead}>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
Expand Down
Loading