391 lines
16 KiB
C#
391 lines
16 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Text.Json.Serialization;
|
|
using System.Threading.Tasks;
|
|
using FinlyticBackend.Settings;
|
|
using FinlyticBackend.Util;
|
|
using FinlyticCore.Dtos;
|
|
using FinlyticCore.Dtos.Settings;
|
|
using FinlyticCore.Services;
|
|
using FinlyticCore.Util;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Cors;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace FinlyticBackend.Controllers;
|
|
|
|
/// <summary>
|
|
/// Represents a single configuration item for a microservice.
|
|
/// </summary>
|
|
public record ServiceConfigItem(
|
|
string Key,
|
|
string Value,
|
|
string DataType,
|
|
string Description,
|
|
DateTime UpdatedAt
|
|
);
|
|
|
|
/// <summary>
|
|
/// DTO for returning service configuration items (AOT-compliant).
|
|
/// </summary>
|
|
public record ServiceConfigItemResponseDto(
|
|
[property: JsonPropertyName("key")] string Key,
|
|
[property: JsonPropertyName("value")] string Value,
|
|
[property: JsonPropertyName("dataType")] string DataType,
|
|
[property: JsonPropertyName("description")] string Description,
|
|
[property: JsonPropertyName("updatedAt")] DateTime UpdatedAt
|
|
);
|
|
|
|
/// <summary>
|
|
/// Result DTO for a service settings update request, reporting whether the RPC broadcast to the
|
|
/// targeted microservice actually succeeded (AOT-compliant; replaces an anonymous response object).
|
|
/// </summary>
|
|
public record ServiceSettingsUpdateResultDto(
|
|
[property: JsonPropertyName("message")] string Message,
|
|
[property: JsonPropertyName("mqttDispatched")] bool MqttDispatched
|
|
);
|
|
|
|
/// <summary>
|
|
/// DTO representing the operational health status of a microservice (AOT-compliant).
|
|
/// </summary>
|
|
public record ServiceHealthStatusDto(
|
|
[property: JsonPropertyName("name")] string Name,
|
|
[property: JsonPropertyName("type")] string Type,
|
|
[property: JsonPropertyName("status")] string Status,
|
|
[property: JsonPropertyName("port")] string Port,
|
|
[property: JsonPropertyName("communication")] string Communication,
|
|
[property: JsonPropertyName("db")] string Db,
|
|
[property: JsonPropertyName("lastPing")] DateTime LastPing
|
|
);
|
|
|
|
[ApiController]
|
|
[Route("api/v1/admin/settings")]
|
|
[Authorize(Roles = "Admin")]
|
|
[EnableCors("AllowAll")]
|
|
public class AdminSettingsController : ControllerBase
|
|
{
|
|
private readonly WebMqttClient _mqttClient;
|
|
private readonly ILogger<AdminSettingsController> _logger;
|
|
|
|
private static readonly Dictionary<string, string> ServiceRpcPrefixes = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["FinlyticFundamentals"] = "fundamentals",
|
|
["FinlyticNews"] = "news",
|
|
["FinlyticTechnicals"] = "ta",
|
|
["FinlyticTechnicalAnalysis"] = "ta",
|
|
["FinlyticSentiment"] = "sentiment",
|
|
["FinlyticAssets"] = "assets",
|
|
["FinlyticEngine"] = "engine",
|
|
["FinlyticAnalyzer"] = "engine",
|
|
["FinlyticTrades"] = "engine",
|
|
["FinlyticBot"] = "bot",
|
|
["FinlyticNotify"] = "notify",
|
|
// FinlyticSimulation was previously missing from this map entirely, so its settings
|
|
// (SimulationSettingKeys: slippage/fee defaults, matrix-recompute schedule, etc.) never showed up
|
|
// in the admin UI's settings screen even though the sim_settings_GetAll/Update RPC channels exist.
|
|
["FinlyticSimulation"] = "sim"
|
|
};
|
|
|
|
/// <summary>
|
|
/// FinlyticBackend is the RPC *caller* for every entry in <see cref="ServiceRpcPrefixes"/> above, not a
|
|
/// callee - it has no MQTT-served "backend_settings_GetAll" channel to ask, because it would just be
|
|
/// asking itself over the network for no reason. Its own settings (<see cref="BackendSettingKeys"/>) are
|
|
/// instead read directly, in-process, via <see cref="_settingsService"/> - see the special-casing in
|
|
/// <see cref="GetAllSettings"/>/<see cref="GetServiceSettings"/>/<see cref="UpdateServiceSettings"/>.
|
|
/// </summary>
|
|
private const string BackendServiceName = "FinlyticBackend";
|
|
|
|
private static readonly ConcurrentDictionary<string, List<ServiceConfigItem>> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private readonly ISettingsService _settingsService;
|
|
|
|
public AdminSettingsController(
|
|
WebMqttClient mqttClient,
|
|
ISettingsService settingsService,
|
|
ILogger<AdminSettingsController> logger)
|
|
{
|
|
_mqttClient = mqttClient;
|
|
_settingsService = settingsService;
|
|
_logger = logger;
|
|
}
|
|
|
|
private async Task<List<ServiceConfigItemResponseDto>> GetBackendOwnSettingsAsync()
|
|
{
|
|
var settings = await _settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(BackendSettingKeys) });
|
|
return settings.Select(d => new ServiceConfigItemResponseDto(
|
|
Key: d.Key,
|
|
Value: d.Value?.ToString() ?? "",
|
|
DataType: d.Type,
|
|
Description: d.Description,
|
|
UpdatedAt: d.UpdatedAt ?? DateTime.UtcNow
|
|
)).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves recent buffered in-memory logs for a specific service.
|
|
/// </summary>
|
|
[HttpGet("logs/{serviceName}")]
|
|
public IActionResult GetServiceLogs(string serviceName)
|
|
{
|
|
if (BackendMqttBridge.ServiceLogsRingBuffer.TryGetValue(serviceName, out var queue))
|
|
{
|
|
return Ok(queue.ToList());
|
|
}
|
|
return Ok(new List<FinlyticCore.Dtos.Logging.LogMessageDto>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves all service configurations grouped by service name via live MQTT RPC queries.
|
|
/// </summary>
|
|
[HttpGet]
|
|
public async Task<IActionResult> GetAllSettings()
|
|
{
|
|
if (_mqttClient.IsConnected)
|
|
{
|
|
var fetchTasks = ServiceRpcPrefixes.Select(async kvp =>
|
|
{
|
|
var serviceName = kvp.Key;
|
|
var prefix = kvp.Value;
|
|
try
|
|
{
|
|
var liveSettings = await _mqttClient.SendRpcRequestAsync<List<DynamicSettingDto>, string>(
|
|
$"{prefix}_settings_GetAll",
|
|
"",
|
|
TimeSpan.FromSeconds(2));
|
|
|
|
if (liveSettings != null && liveSettings.Count > 0)
|
|
{
|
|
_inMemorySettings[serviceName] = liveSettings.Select(d => new ServiceConfigItem(
|
|
Key: d.Key,
|
|
Value: d.Value?.ToString() ?? "",
|
|
DataType: d.Type,
|
|
Description: d.Description,
|
|
UpdatedAt: d.UpdatedAt ?? DateTime.UtcNow
|
|
)).ToList();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[AdminSettings] Could not fetch live settings from {ServiceName} via MQTT.", serviceName);
|
|
}
|
|
});
|
|
|
|
await Task.WhenAll(fetchTasks);
|
|
}
|
|
|
|
var grouped = _inMemorySettings.ToDictionary(
|
|
g => g.Key,
|
|
g => g.Value.Select(item => new ServiceConfigItemResponseDto(
|
|
Key: item.Key,
|
|
Value: item.Value,
|
|
DataType: item.DataType,
|
|
Description: item.Description,
|
|
UpdatedAt: item.UpdatedAt
|
|
)).ToList()
|
|
);
|
|
|
|
// FinlyticBackend's own settings never go through the MQTT RPC loop above (see BackendServiceName's
|
|
// doc comment) - read directly, in-process.
|
|
grouped[BackendServiceName] = await GetBackendOwnSettingsAsync();
|
|
|
|
return Ok(grouped);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs live MQTT RPC health pings across all microservices and returns their real operational status.
|
|
/// </summary>
|
|
[HttpGet("health")]
|
|
public async Task<IActionResult> GetServicesHealth()
|
|
{
|
|
var servicesToCheck = new (string Name, string Channel, string Type, string Db)[]
|
|
{
|
|
("FinlyticAssets", $"{MqttTopics.Channels.HealthPing}/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
|
|
("FinlyticNews", $"{MqttTopics.Channels.HealthPing}/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
|
|
("FinlyticTechnicals", $"{MqttTopics.Channels.HealthPing}/FinlyticTechnicals", "Technical Indicators & SMC Patterns", "PostgreSQL ta"),
|
|
("FinlyticSentiment", $"{MqttTopics.Channels.HealthPing}/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
|
|
("FinlyticFundamentals", $"{MqttTopics.Channels.HealthPing}/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
|
|
("FinlyticEngine", $"{MqttTopics.Channels.HealthPing}/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
|
|
("FinlyticBot", $"{MqttTopics.Channels.HealthPing}/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
|
|
("FinlyticNotify", $"{MqttTopics.Channels.HealthPing}/FinlyticNotify", "ntfy Push Notifications", "PostgreSQL / Mosquitto"),
|
|
};
|
|
|
|
var results = new List<ServiceHealthStatusDto>
|
|
{
|
|
new(
|
|
Name: "FinlyticBackend",
|
|
Type: "REST API & SignalR Gateway",
|
|
Status: "Online",
|
|
Port: "5000",
|
|
Communication: "Kestrel HTTP / WebSocket",
|
|
Db: "PostgreSQL backend",
|
|
LastPing: DateTime.UtcNow
|
|
)
|
|
};
|
|
|
|
var tasks = servicesToCheck.Select(async s =>
|
|
{
|
|
try
|
|
{
|
|
if (_mqttClient.IsConnected)
|
|
{
|
|
var resp = await _mqttClient.SendRpcRequestAsync<ServiceHealthResponse, EmptyRequest>(
|
|
s.Channel,
|
|
new EmptyRequest(),
|
|
TimeSpan.FromMilliseconds(1200)
|
|
);
|
|
|
|
if (resp != null)
|
|
{
|
|
return new ServiceHealthStatusDto(
|
|
Name: s.Name,
|
|
Type: s.Type,
|
|
Status: "Online",
|
|
Port: "MQTT Only (No HTTP Port)",
|
|
Communication: "MQTT RPC & Pub/Sub",
|
|
Db: s.Db,
|
|
LastPing: resp.Timestamp
|
|
);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[AdminSettings] Health ping timed out or failed for service {ServiceName}", s.Name);
|
|
}
|
|
|
|
return new ServiceHealthStatusDto(
|
|
Name: s.Name,
|
|
Type: s.Type,
|
|
Status: "Offline",
|
|
Port: "MQTT Only (No HTTP Port)",
|
|
Communication: "MQTT (No Response / Timeout)",
|
|
Db: s.Db,
|
|
LastPing: DateTime.UtcNow
|
|
);
|
|
});
|
|
|
|
var pingResults = await Task.WhenAll(tasks);
|
|
results.AddRange(pingResults);
|
|
|
|
return Ok(results);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves settings for a specific service via live MQTT RPC.
|
|
/// </summary>
|
|
[HttpGet("{serviceName}")]
|
|
public async Task<IActionResult> GetServiceSettings(string serviceName)
|
|
{
|
|
if (string.Equals(serviceName, BackendServiceName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return Ok(await GetBackendOwnSettingsAsync());
|
|
}
|
|
|
|
if (ServiceRpcPrefixes.TryGetValue(serviceName, out var prefix) && _mqttClient.IsConnected)
|
|
{
|
|
try
|
|
{
|
|
var liveSettings = await _mqttClient.SendRpcRequestAsync<List<DynamicSettingDto>, string>(
|
|
$"{prefix}_settings_GetAll",
|
|
"",
|
|
TimeSpan.FromSeconds(2));
|
|
|
|
if (liveSettings != null && liveSettings.Count > 0)
|
|
{
|
|
_inMemorySettings[serviceName] = liveSettings.Select(d => new ServiceConfigItem(
|
|
Key: d.Key,
|
|
Value: d.Value?.ToString() ?? "",
|
|
DataType: d.Type,
|
|
Description: d.Description,
|
|
UpdatedAt: d.UpdatedAt ?? DateTime.UtcNow
|
|
)).ToList();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "[AdminSettings] Could not fetch live settings for {ServiceName} via MQTT.", serviceName);
|
|
}
|
|
}
|
|
|
|
if (_inMemorySettings.TryGetValue(serviceName, out var list))
|
|
{
|
|
var dtos = list.Select(item => new ServiceConfigItemResponseDto(
|
|
Key: item.Key,
|
|
Value: item.Value,
|
|
DataType: item.DataType,
|
|
Description: item.Description,
|
|
UpdatedAt: item.UpdatedAt
|
|
)).ToList();
|
|
|
|
return Ok(dtos);
|
|
}
|
|
|
|
return Ok(new List<ServiceConfigItemResponseDto>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Broadcasts configuration settings to the targeted microservice via MQTT RPC and updates in-memory cache.
|
|
/// </summary>
|
|
[HttpPut("{serviceName}")]
|
|
public async Task<IActionResult> UpdateServiceSettings(string serviceName, [FromBody] Dictionary<string, object?> updatedValues)
|
|
{
|
|
if (updatedValues == null || !updatedValues.Any())
|
|
{
|
|
return Problem(title: "No settings provided for update.", statusCode: StatusCodes.Status400BadRequest);
|
|
}
|
|
|
|
if (string.Equals(serviceName, BackendServiceName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// No MQTT round trip needed - FinlyticBackend updates its own dynamic settings directly, in-process.
|
|
await _settingsService.UpdateSettingsAsync(updatedValues);
|
|
return Ok(new ServiceSettingsUpdateResultDto(
|
|
Message: $"Settings successfully updated for {BackendServiceName}.",
|
|
MqttDispatched: false
|
|
));
|
|
}
|
|
|
|
_logger.LogInformation("[AdminSettings] Transmitting {Count} config settings to microservice '{ServiceName}' via MQTT", updatedValues.Count, serviceName);
|
|
|
|
bool mqttPublished = false;
|
|
try
|
|
{
|
|
if (_mqttClient.IsConnected && ServiceRpcPrefixes.TryGetValue(serviceName, out var prefix))
|
|
{
|
|
var updated = await _mqttClient.SendRpcRequestAsync<List<DynamicSettingDto>, Dictionary<string, object?>>(
|
|
$"{prefix}_settings_Update",
|
|
updatedValues,
|
|
TimeSpan.FromSeconds(3));
|
|
|
|
if (updated != null && updated.Count > 0)
|
|
{
|
|
_inMemorySettings[serviceName] = updated.Select(d => new ServiceConfigItem(
|
|
Key: d.Key,
|
|
Value: d.Value?.ToString() ?? "",
|
|
DataType: d.Type,
|
|
Description: d.Description,
|
|
UpdatedAt: d.UpdatedAt ?? DateTime.UtcNow
|
|
)).ToList();
|
|
}
|
|
|
|
// Settings propagation happens entirely over the {prefix}_settings_Update RPC call above.
|
|
// There used to be an additional fire-and-forget MQTT publish to a per-service config-updated
|
|
// topic here, but no service in the fleet ever subscribed to it (dead legacy code predating the
|
|
// RPC mechanism) - removed rather than kept as a no-op broadcast.
|
|
mqttPublished = updated != null && updated.Count > 0;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "[AdminSettings] Failed to publish MQTT config update event for service '{ServiceName}'", serviceName);
|
|
}
|
|
|
|
return Ok(new ServiceSettingsUpdateResultDto(
|
|
Message: $"Settings successfully transmitted to microservice {serviceName}.",
|
|
MqttDispatched: mqttPublished
|
|
));
|
|
}
|
|
} |