diff --git a/django-backend/soroscan/health.py b/django-backend/soroscan/health.py index 735b9507..f8f84841 100644 --- a/django-backend/soroscan/health.py +++ b/django-backend/soroscan/health.py @@ -1,6 +1,8 @@ """ Health check endpoints for Kubernetes liveness/readiness probes. """ +import time + from django.core.cache import cache from django.db import connection from rest_framework.decorators import api_view, permission_classes @@ -8,11 +10,35 @@ from rest_framework.response import Response +PROCESS_START_TIME = time.monotonic() + + +def get_uptime_seconds() -> int: + """Return how long the current Django process has been running.""" + return max(1, int(time.monotonic() - PROCESS_START_TIME)) + + +def format_uptime(seconds: int) -> str: + """Format uptime as human-readable days, hours, minutes, and seconds.""" + days, remainder = divmod(seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{days}d {hours:02}:{minutes:02}:{seconds:02}" + + @api_view(["GET"]) @permission_classes([AllowAny]) def health_view(request): """Liveness probe - app is running.""" - return Response({"status": "ok"}) + uptime_seconds = get_uptime_seconds() + + return Response( + { + "status": "ok", + "uptime_seconds": uptime_seconds, + "uptime": format_uptime(uptime_seconds), + } + ) @api_view(["GET"]) diff --git a/django-backend/soroscan/ingest/tests/test_health.py b/django-backend/soroscan/ingest/tests/test_health.py index 719e1a8e..90c7424b 100644 --- a/django-backend/soroscan/ingest/tests/test_health.py +++ b/django-backend/soroscan/ingest/tests/test_health.py @@ -5,6 +5,7 @@ from rest_framework import status from rest_framework.test import APIClient +from soroscan import health @pytest.fixture @@ -14,14 +15,47 @@ def api_client(): @pytest.mark.django_db class TestHealthView: - def test_health_returns_ok(self, api_client): + def test_health_returns_ok_with_uptime(self, api_client): url = reverse("health") response = api_client.get(url) assert response.status_code == status.HTTP_200_OK - assert response.data == {"status": "ok"} + assert response.data["status"] == "ok" + assert "uptime_seconds" in response.data + assert "uptime" in response.data + assert isinstance(response.data["uptime_seconds"], int) + assert response.data["uptime_seconds"] > 0 + assert isinstance(response.data["uptime"], str) assert response["X-SoroScan-Version"] == settings.SOFTWARE_VERSION + def test_health_uptime_is_accurate(self, api_client, monkeypatch): + monkeypatch.setattr(health, "PROCESS_START_TIME", 100.0) + monkeypatch.setattr(health.time, "monotonic", lambda: 165.0) + + url = reverse("health") + response = api_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["uptime_seconds"] == 65 + assert response.data["uptime"] == "0d 00:01:05" + + def test_health_uptime_counter_increases_across_requests(self, api_client, monkeypatch): + times = iter([110.0, 125.0]) + + monkeypatch.setattr(health, "PROCESS_START_TIME", 100.0) + monkeypatch.setattr(health.time, "monotonic", lambda: next(times)) + + url = reverse("health") + + first_response = api_client.get(url) + second_response = api_client.get(url) + + assert first_response.status_code == status.HTTP_200_OK + assert second_response.status_code == status.HTTP_200_OK + assert first_response.data["uptime_seconds"] == 10 + assert second_response.data["uptime_seconds"] == 25 + assert second_response.data["uptime_seconds"] > first_response.data["uptime_seconds"] + @pytest.mark.django_db class TestReadinessView: diff --git a/soroscan-frontend/__tests__/event-table-responsive.test.tsx b/soroscan-frontend/__tests__/event-table-responsive.test.tsx new file mode 100644 index 00000000..fe079e6e --- /dev/null +++ b/soroscan-frontend/__tests__/event-table-responsive.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; + +import { EventTable } from "@/app/dashboard/components/EventTable"; +import type { EventRecord } from "@/components/ingest/types"; + +const mockEvents: EventRecord[] = [ + { + id: "event-1", + contractId: "contract-1234567890abcdef", + contractName: "Test Contract", + eventType: "transfer", + ledger: 12345, + eventIndex: 1, + timestamp: "2026-05-29T10:00:00.000Z", + txHash: "tx-1234567890abcdef", + payload: { amount: "100" }, + payloadHash: "payload-123", + schemaVersion: "1.0", + validationStatus: "valid", + }, +]; + +describe("EventTable responsive card grid", () => { + it("renders the mobile card grid container", () => { + render( + , + ); + + expect(screen.getByTestId("events-card-grid")).toBeInTheDocument(); + }); + + it("shows event information inside the mobile cards", () => { + render( + , + ); + + expect(screen.getAllByText("transfer").length).toBeGreaterThan(0); + + expect(screen.getAllByText("12345").length).toBeGreaterThan(0); + + expect(screen.getAllByText(/contract\.\.\.abcdef/i).length).toBeGreaterThan( + 0, + ); + + expect(screen.getAllByText(/tx-12345\.\.\.abcdef/i).length).toBeGreaterThan( + 0, + ); + }); + + it("calls onEventClick when card detail button is clicked", () => { + const onEventClick = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getAllByText(/View/i)[1]); + + expect(onEventClick).toHaveBeenCalledWith(mockEvents[0]); + }); + + it("shows loading state", () => { + render(); + + expect(screen.getByText("Loading events...")).toBeInTheDocument(); + }); + + it("shows empty state when no events are available", () => { + render(); + + expect(screen.getByText(/No events found/i)).toBeInTheDocument(); + }); +}); diff --git a/soroscan-frontend/app/dashboard/components/EventTable.tsx b/soroscan-frontend/app/dashboard/components/EventTable.tsx index 56e39cd1..924acd2e 100644 --- a/soroscan-frontend/app/dashboard/components/EventTable.tsx +++ b/soroscan-frontend/app/dashboard/components/EventTable.tsx @@ -44,7 +44,9 @@ export function EventTable({ }; const getEventTypeColor = (eventType: string): string => { - const hash = eventType.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0); + const hash = eventType + .split("") + .reduce((acc, char) => acc + char.charCodeAt(0), 0); const colors = [ "rgba(0, 255, 156, 0.8)", "rgba(0, 212, 255, 0.8)", @@ -103,9 +105,105 @@ export function EventTable({ ); } + if (!events.length) { + return ( +
+
+ No events found. Select a contract and adjust filters to view events. +
+
+ ); + } + return (
- + + +
@@ -118,215 +216,183 @@ export function EventTable({ - {!events.length ? ( - - - - ) : ( - events.map((event) => ( - { - e.currentTarget.style.boxShadow = `0 0 15px ${getEventTypeColor(event.eventType)}`; - }} - onMouseLeave={(e) => { - e.currentTarget.style.boxShadow = "none"; - }} - > - - - onEventClick(event)} + onMouseEnter={(e) => { + e.currentTarget.style.boxShadow = `0 0 15px ${getEventTypeColor(event.eventType)}`; + }} + onMouseLeave={(e) => { + e.currentTarget.style.boxShadow = "none"; + }} + > + - - - {showTags && ( - - )} - + + + + - - )) - )} + + + + + ))}
Contract
- {loading ? ( - "Loading events..." - ) : hasActiveFilters ? ( -
-
- - - - -
-

- No events match your criteria -

-

- We couldn't find any events matching your current search and filter settings. Try adjusting them or clear all filters to see more results. -

- -
- ) : ( - "No events found. Select a contract and adjust filters to view events." - )} -
-
- {shortHash(event.contractId)} - -
-
- - {event.eventType} - - + {events.map((event) => ( +
+
+ {shortHash(event.contractId)} -
{formatDateTime(event.timestamp)} -
- {shortHash(event.txHash)} - -
-
-
-
- {(eventTags[event.id] ?? []).map((tag) => ( - - {tag} - - - ))} -
-
- e.stopPropagation()} - onChange={(e) => { - const value = e.target.value; - setTagInputs((prev) => ({ ...prev, [event.id]: value })); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - e.stopPropagation(); - const value = tagInputs[event.id] ?? ""; - onAddTag(event.id, value); - setTagInputs((prev) => ({ ...prev, [event.id]: "" })); - } - }} - style={{ padding: "0.35rem 0.45rem", fontSize: "0.75rem" }} - /> - -
- - {tagSuggestions.map((tag) => ( - -
-
+ + + + {event.eventType} + + {event.ledger}{formatDateTime(event.timestamp)} +
+ {shortHash(event.txHash)} -
+ +
+ +
+ {events.map((event) => ( +
+
+
+ Event Type + + {event.eventType} + +
+ +
+ +
+
+ Contract + + {shortHash(event.contractId)} + +
+ +
+ Ledger + + {event.ledger} + +
+ +
+ Time + + {formatDateTime(event.timestamp)} + +
+ +
+ Transaction + + {shortHash(event.txHash)} + +
+
+ +
+ +
+
+ ))} +
); -} \ No newline at end of file +}