Add signalr

This commit is contained in:
2025-07-21 19:54:04 +07:00
parent a32e9c33a8
commit 83ed78a1fa
11 changed files with 441 additions and 10 deletions

View File

@@ -27,7 +27,7 @@ namespace Managing.Api.Controllers;
[Produces("application/json")] [Produces("application/json")]
public class BacktestController : BaseController public class BacktestController : BaseController
{ {
private readonly IHubContext<BotHub> _hubContext; private readonly IHubContext<BacktestHub> _hubContext;
private readonly IBacktester _backtester; private readonly IBacktester _backtester;
private readonly IScenarioService _scenarioService; private readonly IScenarioService _scenarioService;
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
@@ -45,7 +45,7 @@ public class BacktestController : BaseController
/// <param name="geneticService">The service for genetic algorithm operations.</param> /// <param name="geneticService">The service for genetic algorithm operations.</param>
/// <param name="backtestRepository">The repository for backtest operations.</param> /// <param name="backtestRepository">The repository for backtest operations.</param>
public BacktestController( public BacktestController(
IHubContext<BotHub> hubContext, IHubContext<BacktestHub> hubContext,
IBacktester backtester, IBacktester backtester,
IScenarioService scenarioService, IScenarioService scenarioService,
IAccountService accountService, IAccountService accountService,
@@ -537,6 +537,47 @@ public class BacktestController : BaseController
}); });
} }
/// <summary>
/// Subscribes the client to real-time updates for a bundle backtest request via SignalR.
/// The client will receive LightBacktestResponse objects as new backtests are generated.
/// </summary>
/// <param name="requestId">The bundle request ID to subscribe to.</param>
[HttpPost]
[Route("Bundle/Subscribe")] // POST /Backtest/Bundle/Subscribe
public async Task<IActionResult> SubscribeToBundle([FromQuery] string requestId)
{
if (string.IsNullOrWhiteSpace(requestId))
return BadRequest("RequestId is required");
// Get the connection ID from the SignalR context (assume it's passed via header or query)
var connectionId = HttpContext.Request.Headers["X-SignalR-ConnectionId"].ToString();
if (string.IsNullOrEmpty(connectionId))
return BadRequest("SignalR connection ID is required in X-SignalR-ConnectionId header");
// Add the connection to the SignalR group for this bundle
await _hubContext.Groups.AddToGroupAsync(connectionId, $"bundle-{requestId}");
return Ok(new { Subscribed = true, RequestId = requestId });
}
/// <summary>
/// Unsubscribes the client from real-time updates for a bundle backtest request via SignalR.
/// </summary>
/// <param name="requestId">The bundle request ID to unsubscribe from.</param>
[HttpPost]
[Route("Bundle/Unsubscribe")] // POST /Backtest/Bundle/Unsubscribe
public async Task<IActionResult> UnsubscribeFromBundle([FromQuery] string requestId)
{
if (string.IsNullOrWhiteSpace(requestId))
return BadRequest("RequestId is required");
var connectionId = HttpContext.Request.Headers["X-SignalR-ConnectionId"].ToString();
if (string.IsNullOrEmpty(connectionId))
return BadRequest("SignalR connection ID is required in X-SignalR-ConnectionId header");
await _hubContext.Groups.RemoveFromGroupAsync(connectionId, $"bundle-{requestId}");
return Ok(new { Unsubscribed = true, RequestId = requestId });
}
/// <summary> /// <summary>
/// Runs a genetic algorithm optimization with the specified configuration. /// Runs a genetic algorithm optimization with the specified configuration.
/// This endpoint saves the genetic request to the database and returns the request ID. /// This endpoint saves the genetic request to the database and returns the request ID.

View File

@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Managing.Domain.Backtests;
using Managing.Domain.Bots; using Managing.Domain.Bots;
namespace Managing.Api.Models.Requests; namespace Managing.Api.Models.Requests;
@@ -19,4 +20,28 @@ public class LightBacktestResponse
[Required] public double? SharpeRatio { get; set; } [Required] public double? SharpeRatio { get; set; }
[Required] public double Score { get; set; } [Required] public double Score { get; set; }
[Required] public string ScoreMessage { get; set; } = string.Empty; [Required] public string ScoreMessage { get; set; } = string.Empty;
}
public static class LightBacktestResponseMapper
{
public static LightBacktestResponse MapFromDomain(Backtest b)
{
if (b == null) return null;
return new LightBacktestResponse
{
Id = b.Id,
Config = b.Config,
FinalPnl = b.FinalPnl,
WinRate = b.WinRate,
GrowthPercentage = b.GrowthPercentage,
HodlPercentage = b.HodlPercentage,
StartDate = b.StartDate,
EndDate = b.EndDate,
MaxDrawdown = b.Statistics?.MaxDrawdown,
Fees = b.Fees,
SharpeRatio = (double?)b.Statistics?.SharpeRatio,
Score = b.Score,
ScoreMessage = b.ScoreMessage
};
}
} }

View File

@@ -231,7 +231,7 @@ public class BundleBacktestWorker : BaseWorker<BundleBacktestWorker>
backtestConfig, backtestConfig,
runBacktestRequest.StartDate, runBacktestRequest.StartDate,
runBacktestRequest.EndDate, runBacktestRequest.EndDate,
null, // No user context in worker bundleRequest.User, // No user context in worker
runBacktestRequest.Save, runBacktestRequest.Save,
runBacktestRequest.WithCandles, runBacktestRequest.WithCandles,
bundleRequest.RequestId // Use bundleRequestId as requestId for traceability bundleRequest.RequestId // Use bundleRequestId as requestId for traceability

View File

@@ -2,6 +2,7 @@
using Managing.Application.Abstractions.Repositories; using Managing.Application.Abstractions.Repositories;
using Managing.Application.Abstractions.Services; using Managing.Application.Abstractions.Services;
using Managing.Application.Bots; using Managing.Application.Bots;
using Managing.Application.Hubs;
using Managing.Core.FixedSizedQueue; using Managing.Core.FixedSizedQueue;
using Managing.Domain.Accounts; using Managing.Domain.Accounts;
using Managing.Domain.Backtests; using Managing.Domain.Backtests;
@@ -13,8 +14,10 @@ using Managing.Domain.Strategies;
using Managing.Domain.Strategies.Base; using Managing.Domain.Strategies.Base;
using Managing.Domain.Users; using Managing.Domain.Users;
using Managing.Domain.Workflows; using Managing.Domain.Workflows;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static Managing.Common.Enums; using static Managing.Common.Enums;
using LightBacktestResponse = Managing.Domain.Backtests.LightBacktest; // Use the domain model for notification
namespace Managing.Application.Backtesting namespace Managing.Application.Backtesting
{ {
@@ -28,6 +31,7 @@ namespace Managing.Application.Backtesting
private readonly IAccountService _accountService; private readonly IAccountService _accountService;
private readonly IMessengerService _messengerService; private readonly IMessengerService _messengerService;
private readonly IKaigenService _kaigenService; private readonly IKaigenService _kaigenService;
private readonly IHubContext<BacktestHub> _hubContext;
public Backtester( public Backtester(
IExchangeService exchangeService, IExchangeService exchangeService,
@@ -37,7 +41,8 @@ namespace Managing.Application.Backtesting
IScenarioService scenarioService, IScenarioService scenarioService,
IAccountService accountService, IAccountService accountService,
IMessengerService messengerService, IMessengerService messengerService,
IKaigenService kaigenService) IKaigenService kaigenService,
IHubContext<BacktestHub> hubContext)
{ {
_exchangeService = exchangeService; _exchangeService = exchangeService;
_botFactory = botFactory; _botFactory = botFactory;
@@ -47,6 +52,7 @@ namespace Managing.Application.Backtesting
_accountService = accountService; _accountService = accountService;
_messengerService = messengerService; _messengerService = messengerService;
_kaigenService = kaigenService; _kaigenService = kaigenService;
_hubContext = hubContext;
} }
public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false) public Backtest RunSimpleBotBacktest(Workflow workflow, bool save = false)
@@ -604,5 +610,14 @@ namespace Managing.Application.Backtesting
{ {
return _backtestRepository.GetPendingBundleBacktestRequests(); return _backtestRepository.GetPendingBundleBacktestRequests();
} }
/// <summary>
/// Sends a LightBacktestResponse to all SignalR subscribers of a bundle request.
/// </summary>
public async Task SendBundleBacktestUpdateAsync(string requestId, LightBacktestResponse response)
{
if (string.IsNullOrWhiteSpace(requestId) || response == null) return;
await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", response);
}
} }
} }

View File

@@ -4,12 +4,20 @@ namespace Managing.Application.Hubs;
public class BacktestHub : Hub public class BacktestHub : Hub
{ {
public async override Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
await base.OnConnectedAsync(); await base.OnConnectedAsync();
await Clients.Caller.SendAsync("Message", $"Connected successfully on backtest hub. ConnectionId : {Context.ConnectionId}"); await Clients.Caller.SendAsync("Message", "Connected to BacktestHub!");
} }
public async Task SubscribeBots() => public async Task SubscribeToBundle(string requestId)
await Clients.All.SendAsync("BacktestsSubscription", "Successfully subscribed"); {
if (!string.IsNullOrWhiteSpace(requestId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"bundle-{requestId}");
await Clients.Caller.SendAsync("SubscribedToBundle", requestId);
}
}
public string GetConnectionId() => Context.ConnectionId;
} }

View File

@@ -0,0 +1,73 @@
using System.Collections.Concurrent;
using Managing.Application.Abstractions.Services;
using Managing.Application.Hubs;
using Managing.Application.Workers.Abstractions;
using Managing.Domain.Backtests;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using static Managing.Common.Enums;
namespace Managing.Application.Workers;
public class NotifyBundleBacktestWorker : BaseWorker<NotifyBundleBacktestWorker>
{
private readonly IBacktester _backtester;
private readonly IHubContext<BacktestHub> _hubContext;
private readonly ConcurrentDictionary<string, HashSet<string>> _sentBacktestIds = new();
public NotifyBundleBacktestWorker(
IBacktester backtester,
IHubContext<BacktestHub> hubContext,
ILogger<NotifyBundleBacktestWorker> logger,
IWorkerService workerService)
: base(WorkerType.NotifyBundleBacktest, logger, TimeSpan.FromMinutes(1), workerService)
{
_backtester = backtester;
_hubContext = hubContext;
}
protected override async Task Run(CancellationToken stoppingToken)
{
try
{
// Fetch all running bundle requests
var runningBundles = _backtester.GetPendingBundleBacktestRequests()
.Where(b => b.Status == BundleBacktestRequestStatus.Running)
.ToList();
foreach (var bundle in runningBundles)
{
var requestId = bundle.RequestId;
if (string.IsNullOrEmpty(requestId)) continue;
// Fetch all backtests for this bundle
var (backtests, _) = _backtester.GetBacktestsByRequestIdPaginated(requestId, 1, 100);
if (!_sentBacktestIds.ContainsKey(requestId))
_sentBacktestIds[requestId] = new HashSet<string>();
foreach (var backtest in backtests)
{
if (_sentBacktestIds[requestId].Contains(backtest.Id)) continue;
// If backtest is already LightBacktest, send directly
var lightResponse = backtest as LightBacktest;
if (lightResponse != null)
{
await _hubContext.Clients.Group($"bundle-{requestId}").SendAsync("BundleBacktestUpdate", lightResponse, stoppingToken);
_sentBacktestIds[requestId].Add(backtest.Id);
}
}
// If the bundle is now completed, flush the sent IDs for this requestId
if (bundle.Status == BundleBacktestRequestStatus.Completed && _sentBacktestIds.ContainsKey(requestId))
{
_sentBacktestIds.TryRemove(requestId, out _);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in NotifyBundleBacktestWorker");
}
}
}

View File

@@ -171,6 +171,11 @@ public static class ApiBootstrap
services.AddHostedService<BalanceTrackingWorker>(); services.AddHostedService<BalanceTrackingWorker>();
} }
if (configuration.GetValue<bool>("WorkerNotifyBundleBacktest", false))
{
services.AddHostedService<NotifyBundleBacktestWorker>();
}
return services; return services;
} }

View File

@@ -384,7 +384,8 @@ public static class Enums
FundingRatesWatcher, FundingRatesWatcher,
BalanceTracking, BalanceTracking,
GeneticAlgorithm, GeneticAlgorithm,
BundleBacktest BundleBacktest,
NotifyBundleBacktest
} }
public enum WorkflowUsage public enum WorkflowUsage

View File

@@ -871,6 +871,90 @@ export class BacktestClient extends AuthorizedApiBase {
return Promise.resolve<FileResponse>(null as any); return Promise.resolve<FileResponse>(null as any);
} }
backtest_SubscribeToBundle(requestId: string | null | undefined): Promise<FileResponse> {
let url_ = this.baseUrl + "/Backtest/Bundle/Subscribe?";
if (requestId !== undefined && requestId !== null)
url_ += "requestId=" + encodeURIComponent("" + requestId) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/octet-stream"
}
};
return this.transformOptions(options_).then(transformedOptions_ => {
return this.http.fetch(url_, transformedOptions_);
}).then((_response: Response) => {
return this.processBacktest_SubscribeToBundle(_response);
});
}
protected processBacktest_SubscribeToBundle(response: Response): Promise<FileResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200 || status === 206) {
const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined;
let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined;
let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined;
if (fileName) {
fileName = decodeURIComponent(fileName);
} else {
fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined;
}
return response.blob().then(blob => { return { fileName: fileName, data: blob, status: status, headers: _headers }; });
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<FileResponse>(null as any);
}
backtest_UnsubscribeFromBundle(requestId: string | null | undefined): Promise<FileResponse> {
let url_ = this.baseUrl + "/Backtest/Bundle/Unsubscribe?";
if (requestId !== undefined && requestId !== null)
url_ += "requestId=" + encodeURIComponent("" + requestId) + "&";
url_ = url_.replace(/[?&]$/, "");
let options_: RequestInit = {
method: "POST",
headers: {
"Accept": "application/octet-stream"
}
};
return this.transformOptions(options_).then(transformedOptions_ => {
return this.http.fetch(url_, transformedOptions_);
}).then((_response: Response) => {
return this.processBacktest_UnsubscribeFromBundle(_response);
});
}
protected processBacktest_UnsubscribeFromBundle(response: Response): Promise<FileResponse> {
const status = response.status;
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
if (status === 200 || status === 206) {
const contentDisposition = response.headers ? response.headers.get("content-disposition") : undefined;
let fileNameMatch = contentDisposition ? /filename\*=(?:(\\?['"])(.*?)\1|(?:[^\s]+'.*?')?([^;\n]*))/g.exec(contentDisposition) : undefined;
let fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[3] || fileNameMatch[2] : undefined;
if (fileName) {
fileName = decodeURIComponent(fileName);
} else {
fileNameMatch = contentDisposition ? /filename="?([^"]*?)"?(;|$)/g.exec(contentDisposition) : undefined;
fileName = fileNameMatch && fileNameMatch.length > 1 ? fileNameMatch[1] : undefined;
}
return response.blob().then(blob => { return { fileName: fileName, data: blob, status: status, headers: _headers }; });
} else if (status !== 200 && status !== 204) {
return response.text().then((_responseText) => {
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
});
}
return Promise.resolve<FileResponse>(null as any);
}
backtest_RunGenetic(request: RunGeneticRequest): Promise<GeneticRequest> { backtest_RunGenetic(request: RunGeneticRequest): Promise<GeneticRequest> {
let url_ = this.baseUrl + "/Backtest/Genetic"; let url_ = this.baseUrl + "/Backtest/Genetic";
url_ = url_.replace(/[?&]$/, ""); url_ = url_.replace(/[?&]$/, "");

View File

@@ -0,0 +1,163 @@
import React, {useEffect, useRef, useState} from 'react';
import {BundleBacktestRequest, LightBacktestResponse} from '../../generated/ManagingApiTypes';
import {BacktestClient} from '../../generated/ManagingApi';
import useApiUrlStore from '../../app/store/apiStore';
import Toast from '../../components/mollecules/Toast/Toast';
import {useQuery} from '@tanstack/react-query';
import * as signalR from '@microsoft/signalr';
import AuthorizedApiBase from '../../generated/AuthorizedApiBase';
interface BundleRequestModalProps {
open: boolean;
onClose: () => void;
bundle: BundleBacktestRequest | null;
}
const BundleRequestModal: React.FC<BundleRequestModalProps> = ({ open, onClose, bundle }) => {
const { apiUrl } = useApiUrlStore();
const [backtests, setBacktests] = useState<LightBacktestResponse[]>([]);
const signalRRef = useRef<any>(null);
const {
data: queryBacktests,
isLoading,
error: queryError,
refetch
} = useQuery({
queryKey: ['bundle-backtests', bundle?.requestId],
queryFn: async () => {
if (!open || !bundle) return [];
const client = new BacktestClient({} as any, apiUrl);
const res = await client.backtest_GetBacktestsByRequestId(bundle.requestId);
if (!res) return [];
return res.map((b: any) => ({
id: b.id,
config: b.config,
finalPnl: b.finalPnl,
winRate: b.winRate,
growthPercentage: b.growthPercentage,
hodlPercentage: b.hodlPercentage,
startDate: b.startDate,
endDate: b.endDate,
maxDrawdown: b.maxDrawdown ?? null,
fees: b.fees,
sharpeRatio: b.sharpeRatio ?? null,
score: b.score ?? 0,
scoreMessage: b.scoreMessage ?? '',
}));
},
enabled: !!open && !!bundle,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (queryBacktests) setBacktests(queryBacktests);
}, [queryBacktests]);
// SignalR live updates
useEffect(() => {
if (!open || !bundle) return;
if (bundle.status !== 'Pending' && bundle.status !== 'Running') return;
let connection: any = null;
let connectionId: string = '';
let unsubscribed = false;
(async () => {
try {
connection = new signalR.HubConnectionBuilder()
.withUrl(`${apiUrl.replace(/\/$/, '')}/backtestHub`)
.withAutomaticReconnect()
.build();
await connection.start();
connectionId = connection.connectionId;
// Subscribe to bundle updates
const authBase = new AuthorizedApiBase({} as any);
let fetchOptions: any = {
method: 'POST',
headers: { 'X-SignalR-ConnectionId': connectionId },
};
fetchOptions = await authBase.transformOptions(fetchOptions);
await fetch(`${apiUrl}/backtest/Bundle/Subscribe?requestId=${bundle.requestId}`, fetchOptions);
connection.on('BundleBacktestUpdate', (result: LightBacktestResponse) => {
setBacktests((prev) => {
if (prev.some((b) => b.id === result.id)) return prev;
return [...prev, result];
});
});
signalRRef.current = connection;
} catch (e: any) {
new Toast('Failed to subscribe to live updates', false);
}
})();
return () => {
unsubscribed = true;
if (connection && connectionId) {
(async () => {
const authBase = new AuthorizedApiBase({} as any);
let fetchOptions: any = {
method: 'POST',
headers: { 'X-SignalR-ConnectionId': connectionId },
};
fetchOptions = await authBase.transformOptions(fetchOptions);
await fetch(`${apiUrl}/backtest/Bundle/Unsubscribe?requestId=${bundle.requestId}`, fetchOptions);
})();
}
if (signalRRef.current) {
signalRRef.current.stop();
signalRRef.current = null;
}
};
}, [open, bundle, apiUrl]);
if (!open || !bundle) return null;
return (
<div className="modal modal-open">
<div className="modal-box max-w-4xl">
<h3 className="font-bold text-lg mb-2">Bundle: {bundle.name}</h3>
<div className="mb-2 text-sm">
<div><b>Request ID:</b> <span className="font-mono text-xs">{bundle.requestId}</span></div>
<div><b>Status:</b> <span className={`badge badge-sm ml-1`}>{bundle.status}</span></div>
<div><b>Created:</b> {bundle.createdAt ? new Date(bundle.createdAt).toLocaleString() : '-'}</div>
<div><b>Completed:</b> {bundle.completedAt ? new Date(bundle.completedAt).toLocaleString() : '-'}</div>
</div>
<div className="divider">Backtest Results</div>
{isLoading ? (
<div>Loading backtests...</div>
) : queryError ? (
<div className="text-error">{(queryError as any)?.message || 'Failed to fetch backtests'}</div>
) : (
<div className="overflow-x-auto max-h-96">
<table className="table table-zebra w-full text-xs">
<thead>
<tr>
<th>ID</th>
<th>Final PnL</th>
<th>Win Rate</th>
<th>Growth %</th>
<th>Start</th>
<th>End</th>
</tr>
</thead>
<tbody>
{backtests.map((b) => (
<tr key={b.id}>
<td className="font-mono">{b.id}</td>
<td>{b.finalPnl}</td>
<td>{b.winRate}</td>
<td>{b.growthPercentage}</td>
<td>{b.startDate ? new Date(b.startDate).toLocaleString() : '-'}</td>
<td>{b.endDate ? new Date(b.endDate).toLocaleString() : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="modal-action">
<button className="btn" onClick={onClose}>Close</button>
</div>
</div>
</div>
);
};
export default BundleRequestModal;

View File

@@ -4,6 +4,7 @@ import useApiUrlStore from '../../app/store/apiStore';
import Table from '../../components/mollecules/Table/Table'; import Table from '../../components/mollecules/Table/Table';
import {BundleBacktestRequest} from '../../generated/ManagingApiTypes'; import {BundleBacktestRequest} from '../../generated/ManagingApiTypes';
import Toast from '../../components/mollecules/Toast/Toast'; import Toast from '../../components/mollecules/Toast/Toast';
import BundleRequestModal from './BundleRequestModal';
const BundleRequestsTable = () => { const BundleRequestsTable = () => {
const { apiUrl } = useApiUrlStore(); const { apiUrl } = useApiUrlStore();
@@ -11,6 +12,8 @@ const BundleRequestsTable = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [selectedBundle, setSelectedBundle] = useState<BundleBacktestRequest | null>(null);
const fetchData = () => { const fetchData = () => {
setLoading(true); setLoading(true);
@@ -119,7 +122,15 @@ const BundleRequestsTable = () => {
disableSortBy: true, disableSortBy: true,
Cell: ({ row }: any) => ( Cell: ({ row }: any) => (
<div className="flex gap-2"> <div className="flex gap-2">
<button className="btn btn-xs btn-outline" onClick={() => new Toast(`RequestId: ${row.original.requestId}`, false)}>View</button> <button
className="btn btn-xs btn-outline"
onClick={() => {
setSelectedBundle(row.original);
setModalOpen(true);
}}
>
View
</button>
<button <button
className="btn btn-xs btn-error" className="btn btn-xs btn-error"
onClick={() => handleDelete(row.original.requestId)} onClick={() => handleDelete(row.original.requestId)}
@@ -139,6 +150,11 @@ const BundleRequestsTable = () => {
<div className="w-full"> <div className="w-full">
<h2 className="text-lg font-bold mb-2">Bundle Backtest Requests</h2> <h2 className="text-lg font-bold mb-2">Bundle Backtest Requests</h2>
<Table columns={columns} data={data} showPagination={true} /> <Table columns={columns} data={data} showPagination={true} />
<BundleRequestModal
open={modalOpen}
onClose={() => setModalOpen(false)}
bundle={selectedBundle}
/>
</div> </div>
); );
}; };