diff --git a/backend/internal/data/shutter_explorer.sql.go b/backend/internal/data/shutter_explorer.sql.go index 34984c8..ef409f8 100644 --- a/backend/internal/data/shutter_explorer.sql.go +++ b/backend/internal/data/shutter_explorer.sql.go @@ -84,6 +84,32 @@ func (q *Queries) QueryExecutedTransactionStats(ctx context.Context) ([]QueryExe return items, nil } +const queryExecutedTransactionStatsRecent = `-- name: QueryExecutedTransactionStatsRecent :many +SELECT COUNT(id), tx_status FROM decrypted_tx +WHERE created_at >= NOW() - ($1::int * INTERVAL '1 day') +GROUP BY tx_status +` + +func (q *Queries) QueryExecutedTransactionStatsRecent(ctx context.Context, days int32) ([]QueryExecutedTransactionStatsRow, error) { + rows, err := q.db.Query(ctx, queryExecutedTransactionStatsRecent, days) + if err != nil { + return nil, err + } + defer rows.Close() + var items []QueryExecutedTransactionStatsRow + for rows.Next() { + var i QueryExecutedTransactionStatsRow + if err := rows.Scan(&i.Count, &i.TxStatus); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const queryFromTransactionDetails = `-- name: QueryFromTransactionDetails :many SELECT tx_hash as user_tx_hash, encrypted_tx_hash FROM transaction_details diff --git a/backend/internal/data/sql/queries/shutter_explorer.sql b/backend/internal/data/sql/queries/shutter_explorer.sql index c7b5acd..3ee4568 100644 --- a/backend/internal/data/sql/queries/shutter_explorer.sql +++ b/backend/internal/data/sql/queries/shutter_explorer.sql @@ -181,6 +181,11 @@ LIMIT $1; SELECT COUNT(id), tx_status FROM decrypted_tx GROUP BY tx_status; +-- name: QueryExecutedTransactionStatsRecent :many +SELECT COUNT(id), tx_status FROM decrypted_tx +WHERE created_at >= NOW() - ($1::int * INTERVAL '1 day') +GROUP BY tx_status; + -- name: QueryHistoricalInclusionTimes :many WITH daily_inclusion_times AS ( SELECT diff --git a/backend/internal/service/inclusion_time.go b/backend/internal/service/inclusion_time.go index bea4237..04414f8 100644 --- a/backend/internal/service/inclusion_time.go +++ b/backend/internal/service/inclusion_time.go @@ -2,6 +2,7 @@ package service import ( "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/shutter-network/shutter-explorer/backend/internal/usecase" @@ -39,6 +40,23 @@ func (svc *InclusionTimeService) QueryExecutedTransactionStats(ctx *gin.Context) }) } +func (svc *InclusionTimeService) QueryExecutedTransactionStatsRecent(ctx *gin.Context) { + daysStr := ctx.DefaultQuery("days", "30") + days, err := strconv.Atoi(daysStr) + if err != nil || days <= 0 { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid days parameter"}) + return + } + stats, httpErr := svc.InclusionTimeUsecase.QueryExecutedTransactionStatsRecent(ctx, days) + if httpErr != nil { + ctx.Error(httpErr) + return + } + ctx.JSON(http.StatusOK, gin.H{ + "message": stats, + }) +} + func (svc *InclusionTimeService) QueryHistoricalInclusionTimes(ctx *gin.Context) { historicalInclusionTimes, err := svc.InclusionTimeUsecase.QueryHistoricalInclusionTimes(ctx) if err != nil { diff --git a/backend/internal/usecase/inclusion_time.go b/backend/internal/usecase/inclusion_time.go index 6803389..7c8cc8a 100644 --- a/backend/internal/usecase/inclusion_time.go +++ b/backend/internal/usecase/inclusion_time.go @@ -109,6 +109,40 @@ func (uc *InclusionTimeUsecase) QueryExecutedTransactionStats(ctx context.Contex return resp, nil } +func (uc *InclusionTimeUsecase) QueryExecutedTransactionStatsRecent(ctx context.Context, days int) (*QueryExectuedTransactionStatsResp, *error.Http) { + stats, err := uc.observerDBQuery.QueryExecutedTransactionStatsRecent(ctx, int32(days)) + if err != nil { + log.Err(err).Msg("err encountered while querying DB") + err := error.NewHttpError( + "error encountered while querying for data", + "", + http.StatusInternalServerError, + ) + return nil, &err + } + + resp := &QueryExectuedTransactionStatsResp{} + for i := 0; i < len(stats); i++ { + resp.Total += stats[i].Count + switch stats[i].TxStatus { + case data.TxStatusValInvalid: + resp.Invalid = stats[i].Count + case data.TxStatusValNotdecrypted: + resp.NotDecrypted = stats[i].Count + case data.TxStatusValNotincluded: + resp.NotIncluded = stats[i].Count + case data.TxStatusValPending: + resp.Pending = stats[i].Count + case data.TxStatusValShieldedinclusion: + resp.Shielded = stats[i].Count + case data.TxStatusValUnshieldedinclusion: + resp.Unshielded = stats[i].Count + } + } + + return resp, nil +} + func (uc *InclusionTimeUsecase) QueryHistoricalInclusionTimes(ctx context.Context) ([]data.QueryHistoricalInclusionTimesRow, *error.Http) { historicalInclusionTimes, err := uc.observerDBQuery.QueryHistoricalInclusionTimes(ctx) if err != nil { diff --git a/backend/router/router.go b/backend/router/router.go index 22a356e..1f0a6e1 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -52,6 +52,7 @@ func NewRouter(ctx context.Context, usecases *usecase.Usecases) *gin.Engine { inclusionTime := api.Group("inclusion_time") inclusionTime.GET("/estimated_inclusion_time", inclusionTimeService.QueryEstimatedInclusionTime) inclusionTime.GET("/executed_transactions", inclusionTimeService.QueryExecutedTransactionStats) + inclusionTime.GET("/executed_transactions_recent", inclusionTimeService.QueryExecutedTransactionStatsRecent) inclusionTime.GET("/historical_inclusion_time", inclusionTimeService.QueryHistoricalInclusionTimes) } return router diff --git a/frontend/cypress/components/TransactionGauge.cy.tsx b/frontend/cypress/components/TransactionGauge.cy.tsx index d074025..c7412a9 100644 --- a/frontend/cypress/components/TransactionGauge.cy.tsx +++ b/frontend/cypress/components/TransactionGauge.cy.tsx @@ -7,16 +7,29 @@ import { ThemeProvider as MUIThemeProvider } from '@mui/material/styles'; import { ThemeProvider as StyledThemeProvider } from 'styled-components'; import { customTheme, muiTheme } from '../../src/theme'; +const mountGauge = () => { + const mockSocket = { + onopen: cy.stub(), + onmessage: cy.stub(), + onclose: cy.stub(), + onerror: cy.stub(), + }; + return mount( + + + + + + + + + + ); +}; + describe('', () => { - it('renders the transaction gauge with test values', () => { - const mockSocket = { - onopen: cy.stub(), - onmessage: cy.stub(), - onclose: cy.stub(), - onerror: cy.stub(), - }; - - cy.intercept('GET', '/api/inclusion_time/executed_transactions', { + it('renders the gauge with default 30-day window', () => { + cy.intercept('GET', '/api/inclusion_time/executed_transactions_recent?days=30', { statusCode: 200, body: { message: { @@ -25,32 +38,42 @@ describe('', () => { Total: 30 } }, - }).as('getExecutedTransactions'); - - mount( - - - - - - - - - - ); - - cy.wait('@getExecutedTransactions'); + }).as('getRecentTransactions'); + + mountGauge(); + + cy.wait('@getRecentTransactions'); cy.get('div[role="meter"]').invoke('css', 'height', '300px'); cy.get('div[role="meter"]').should('exist').and('be.visible'); + cy.contains('Last 30 days').should('be.visible'); cy.contains('Shielded').should('be.visible'); cy.contains('25').should('be.visible'); - cy.contains('Total').should('be.visible'); cy.contains('30').should('be.visible'); - cy.contains('Unshielded').should('be.visible'); cy.contains('5').should('be.visible'); }); + + it('switches to 7-day window when 7d button is clicked', () => { + cy.intercept('GET', '/api/inclusion_time/executed_transactions_recent?days=30', { + statusCode: 200, + body: { message: { Shielded: 25, Unshielded: 5 } }, + }); + + cy.intercept('GET', '/api/inclusion_time/executed_transactions_recent?days=7', { + statusCode: 200, + body: { message: { Shielded: 10, Unshielded: 2 } }, + }).as('get7DayTransactions'); + + mountGauge(); + + cy.contains('7d').click(); + cy.wait('@get7DayTransactions'); + + cy.contains('Last 7 days').should('be.visible'); + cy.contains('10').should('be.visible'); + cy.contains('2').should('be.visible'); + }); }); diff --git a/frontend/src/modules/TransactionGauge.tsx b/frontend/src/modules/TransactionGauge.tsx index 774610f..9ae1e07 100644 --- a/frontend/src/modules/TransactionGauge.tsx +++ b/frontend/src/modules/TransactionGauge.tsx @@ -1,64 +1,54 @@ -import { Alert, Box } from '@mui/material'; +import { Alert, Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; import OverviewCard from '../components/OverviewCard'; import BasicGauges from '../components/Gauge'; -import { useEffect, useState } from 'react'; -import { useWebSocket } from '../context/WebSocketContext'; +import { useState } from 'react'; import useFetch from '../hooks/useFetch'; -const TransactionGauge = () => { - const { data: transactionStatsData, loading: loadingTransactionStats, error: errorTransactionStats } = useFetch('/api/inclusion_time/executed_transactions'); - const [successfulTransactions, setSuccessfulTransactions] = useState(transactionStatsData?.message?.Shielded || 0); - const [failedTransactions, setFailedTransactions] = useState(transactionStatsData?.message?.Unshielded || 0); - const [, setWebSocketError] = useState(null); - - const { socket } = useWebSocket()!; - - useEffect(() => { - if (socket) { - socket.onmessage = (event: MessageEvent) => { - const websocketEvent = JSON.parse(event.data); - if (websocketEvent.error) { - setWebSocketError(`Error: ${websocketEvent.error.message} (Code: ${websocketEvent.error.code})`); - } else if (websocketEvent.data) { - setWebSocketError(null); - if (websocketEvent.type === 'executed_transactions_updated') { - if ('Shielded' in websocketEvent.data.message && 'Unshielded' in websocketEvent.data.message) { - setSuccessfulTransactions(websocketEvent.data.message.Shielded); - setFailedTransactions(websocketEvent.data.message.Unshielded); - } - } - } - }; +const WINDOW_OPTIONS = [7, 30, 90, 'all'] as const; +type WindowOption = typeof WINDOW_OPTIONS[number]; - socket.onerror = () => { - setWebSocketError('WebSocket error: A connection error occurred'); - }; - } - - return () => { - if (socket) { - socket.onmessage = null; - socket.onerror = null; - } - }; - }, [socket]); +const TransactionGauge = () => { + const [window, setWindow] = useState(30); + const url = window === 'all' + ? '/api/inclusion_time/executed_transactions' + : `/api/inclusion_time/executed_transactions_recent?days=${window}`; + const { data, loading, error } = useFetch(url); - useEffect(() => { - if (transactionStatsData?.message?.Shielded) setSuccessfulTransactions(transactionStatsData.message.Shielded); - if (transactionStatsData?.message?.Unshielded) setFailedTransactions(transactionStatsData.message.Unshielded); - }, [transactionStatsData]); + const shielded: number = data?.message?.Shielded || 0; + const unshielded: number = data?.message?.Unshielded || 0; + const total = shielded + unshielded; - const totalTransactions = successfulTransactions + failedTransactions return ( - {errorTransactionStats ? ( - Error fetching Transaction Stats: {errorTransactionStats.message} + + { if (v !== null) setWindow(v as WindowOption); }} + size="small" + > + {WINDOW_OPTIONS.map((d) => ( + + {d === 'all' ? 'All' : `${d}d`} + + ))} + + + + {window === 'all' ? 'All time' : `Last ${window} days`} + + {error ? ( + Error fetching Transaction Stats: {error.message} ) : ( )} @@ -66,4 +56,4 @@ const TransactionGauge = () => { ); }; -export default TransactionGauge; \ No newline at end of file +export default TransactionGauge;