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
88 changes: 88 additions & 0 deletions client/src/pages/transparency/TransparencyDashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import TransparencyDashboard from "../TransparencyDashboard";

const mockTransparencyData = {
totalRevenueLumens: 372000,
totalBurnedTokens: 96000,
deflationaryRatio: 32,
history: Array.from({ length: 30 }, (_, i) => ({
date: `2026-04-${String(i + 1).padStart(2, "0")}`,
revenue: 12000 + i * 100,
burned: 3200 + i * 10,
})),
};

function makeFetchMock(incidents: unknown[]) {
return vi.fn().mockImplementation((url: string) => {
if (String(url).includes("failover-history")) {
return Promise.resolve({
ok: true,
json: async () => ({ incidents }),
});
}
return Promise.resolve({
ok: true,
json: async () => mockTransparencyData,
});
});
}

beforeEach(() => {
vi.stubGlobal("localStorage", {
getItem: vi.fn().mockReturnValue(null),
setItem: vi.fn(),
removeItem: vi.fn(),
});
});

describe("TransparencyDashboard – failover incident history", () => {
it("shows 'No failover incidents recorded' when history is empty", async () => {
vi.stubGlobal("fetch", makeFetchMock([]));
render(<TransparencyDashboard />);
await waitFor(() =>
expect(screen.getByText("Provider Failover Incident History")).toBeInTheDocument(),
);
expect(screen.getByText("No failover incidents recorded.")).toBeInTheDocument();
});

it("renders an active incident", async () => {
const incidents = [
{
id: "1",
protocolId: "blend",
protocolName: "Blend",
trigger: "stale_data",
reasons: ["data is stale"],
startedAt: "2026-05-01T10:00:00.000Z",
resolved: false,
},
];
vi.stubGlobal("fetch", makeFetchMock(incidents));
render(<TransparencyDashboard />);
await waitFor(() => expect(screen.getByText("Blend")).toBeInTheDocument());
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText(/stale data/i)).toBeInTheDocument();
});

it("renders a recovered incident with duration", async () => {
const incidents = [
{
id: "2",
protocolId: "soroswap",
protocolName: "Soroswap",
trigger: "outage",
reasons: ["status=down"],
startedAt: "2026-05-01T10:00:00.000Z",
recoveredAt: "2026-05-01T10:05:00.000Z",
durationMs: 300000,
resolved: true,
},
];
vi.stubGlobal("fetch", makeFetchMock(incidents));
render(<TransparencyDashboard />);
await waitFor(() => expect(screen.getByText("Soroswap")).toBeInTheDocument());
expect(screen.getByText("Recovered")).toBeInTheDocument();
expect(screen.getByText(/300s outage/i)).toBeInTheDocument();
});
});
81 changes: 80 additions & 1 deletion client/src/pages/transparency/TransparencyDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* the first fetch is in flight.
*/
import { useState, useEffect } from "react";
import { Loader2, TrendingUp, Flame, BarChart2 } from "lucide-react";
import { Loader2, TrendingUp, Flame, BarChart2, AlertTriangle, CheckCircle, Clock } from "lucide-react";
import {
LineChart,
Line,
Expand Down Expand Up @@ -44,6 +44,18 @@ interface TransparencyData {
history: HistoryPoint[];
}

interface FailoverIncident {
id: string;
protocolId: string;
protocolName: string;
trigger: string;
reasons: string[];
startedAt: string;
recoveredAt?: string;
durationMs?: number;
resolved: boolean;
}

// ── Helpers ───────────────────────────────────────────────────────────────

function formatUSD(value: number): string {
Expand Down Expand Up @@ -99,6 +111,7 @@ export default function TransparencyDashboard() {
const [error, setError] = useState<string | null>(null);
const [smokeStatus, setSmokeStatus] = useState<ReturnType<typeof parseSmokeRunResult>>(null);
const [smokeHistory, setSmokeHistory] = useState<Array<ReturnType<typeof parseSmokeRunResult>>>([]);
const [failoverIncidents, setFailoverIncidents] = useState<FailoverIncident[]>([]);

useEffect(() => {
async function fetchData() {
Expand Down Expand Up @@ -141,6 +154,13 @@ export default function TransparencyDashboard() {
}
}, []);

useEffect(() => {
fetch(`${API_BASE}/api/transparency/failover-history`)
.then((res) => (res.ok ? res.json() : Promise.resolve({ incidents: [] })))
.then((data: { incidents: FailoverIncident[] }) => setFailoverIncidents(data.incidents))
.catch(() => setFailoverIncidents([]));
}, []);

// ── Truncate X-axis labels to MM/DD ───────────────────────────────────
const chartData =
data?.history.map((h) => ({
Expand Down Expand Up @@ -268,6 +288,65 @@ export default function TransparencyDashboard() {
</ResponsiveContainer>
</div>

<div className="glass-panel rounded-2xl p-6">
<div className="flex items-center gap-2 mb-4">
<AlertTriangle size={18} className="text-amber-400" />
<h3 className="font-semibold text-white">Provider Failover Incident History</h3>
</div>
{failoverIncidents.length === 0 ? (
<p className="text-sm text-gray-400">No failover incidents recorded.</p>
) : (
<ul className="space-y-3">
{failoverIncidents.slice(0, 10).map((inc) => (
<li
key={inc.id}
className={`rounded-xl border p-3 ${
inc.resolved
? "border-green-500/20 bg-green-500/5"
: "border-amber-500/20 bg-amber-500/5"
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold text-white">
{inc.protocolName}
</span>
<span
className={`flex items-center gap-1 text-xs font-medium ${
inc.resolved ? "text-green-400" : "text-amber-400"
}`}
>
{inc.resolved ? (
<><CheckCircle size={12} /> Recovered</>
) : (
<><Clock size={12} /> Active</>
)}
</span>
</div>
<p className="text-xs text-gray-400 capitalize">
Trigger: {inc.trigger.replace("_", " ")}
</p>
<p className="text-xs text-gray-500 mt-0.5">
Started: {new Date(inc.startedAt).toLocaleString()}
</p>
{inc.recoveredAt && (
<p className="text-xs text-green-400 mt-0.5">
Recovered: {new Date(inc.recoveredAt).toLocaleString()}
{inc.durationMs !== undefined && (
<> ({Math.round(inc.durationMs / 1000)}s outage)</>
)}
</p>
)}
{inc.reasons.length > 0 && (
<p className="text-xs text-gray-500 mt-1 truncate">
{inc.reasons[0]}
</p>
)}
</li>
))}
</ul>
)}
</div>

<div className="glass-panel rounded-2xl p-6">
<h3 className="font-semibold text-white mb-2">Smoke Test Status</h3>
{smokeStatus ? (
Expand Down
65 changes: 65 additions & 0 deletions server/src/routes/transparency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* on every page load.
*/
import { Router, Request, Response } from "express";
import { failoverIncidentHistoryService } from "../services/failoverIncidentHistoryService";

const transparencyRouter = Router();

Expand Down Expand Up @@ -104,5 +105,69 @@ transparencyRouter.get(
},
);

/**
* GET /api/transparency/failover-history
*
* Returns the in-memory failover incident history, newest first.
* Optional query param: ?protocolId=<id>
*/
transparencyRouter.get(
"/failover-history",
(req: Request, res: Response): void => {
const protocolId = req.query.protocolId as string | undefined;
res.json({ incidents: failoverIncidentHistoryService.getHistory(protocolId) });
},
);

/**
* POST /api/transparency/failover-history
*
* Record a new failover incident.
* Body: { protocolId, protocolName, reasons, startedAt? }
*/
transparencyRouter.post(
"/failover-history",
(req: Request, res: Response): void => {
const { protocolId, protocolName, reasons, startedAt } = req.body as {
protocolId?: string;
protocolName?: string;
reasons?: string[];
startedAt?: string;
};
if (!protocolId || !protocolName || !Array.isArray(reasons)) {
res.status(400).json({ error: "protocolId, protocolName, and reasons are required." });
return;
}
const incident = failoverIncidentHistoryService.recordIncident({
protocolId,
protocolName,
reasons,
startedAt,
});
res.status(201).json(incident);
},
);

/**
* POST /api/transparency/failover-history/:protocolId/resolve
*
* Mark the most recent open incident for a protocol as resolved.
*/
transparencyRouter.post(
"/failover-history/:protocolId/resolve",
(req: Request, res: Response): void => {
const { recoveredAt } = req.body as { recoveredAt?: string };
const incident = failoverIncidentHistoryService.resolveIncident(
req.params.protocolId,
recoveredAt,
);
if (!incident) {
res.status(404).json({ error: "No open incident found for this protocol." });
return;
}
res.json(incident);
},
);

export { aggregateTransparencyData };
export default transparencyRouter;
Loading