feat: внедрение RazorConsole TUI с runtime-управлением MCP-инструментами

- Добавлен RazorConsole.Core для интерактивного TUI-дашборда
- ToolRegistryService: живое включение/отключение модулей и отдельных методов
- InMemoryLogSink: кольцевой буфер логов с фильтрацией по модулю
- TUI: 3 таба (Overview, Logs, Settings)
- IToolModule: generic-интерфейс для легкого добавления новых MCP-модулей
- Guard-проверка TryCheckEnabled() во всех существующих инструментах
This commit is contained in:
2026-04-13 17:31:28 +03:00
parent c117d928b0
commit 879becadfe
24 changed files with 826 additions and 11 deletions

View File

@@ -0,0 +1,81 @@
@using LazyBear.MCP.Services.Logging
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
@inject InMemoryLogSink LogSink
@implements IDisposable
<Rows>
<Panel Title="LazyBear MCP" BorderColor="@Spectre.Console.Color.Gold1" Expand="true">
<Rows>
@* Таб-навигация *@
<Columns>
<TextButton Content="[1] Overview"
OnClick="@(() => SetTab(Tab.Overview))"
BackgroundColor="@(_activeTab == Tab.Overview ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
FocusedColor="@Spectre.Console.Color.Blue"
FocusOrder="1" />
<TextButton Content="[2] Logs"
OnClick="@(() => SetTab(Tab.Logs))"
BackgroundColor="@(_activeTab == Tab.Logs ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
FocusedColor="@Spectre.Console.Color.Blue"
FocusOrder="2" />
<TextButton Content="[3] Settings"
OnClick="@(() => SetTab(Tab.Settings))"
BackgroundColor="@(_activeTab == Tab.Settings ? Spectre.Console.Color.DarkBlue : Spectre.Console.Color.Grey23)"
FocusedColor="@Spectre.Console.Color.Blue"
FocusOrder="3" />
</Columns>
@* Контент таба *@
@if (_activeTab == Tab.Overview)
{
<OverviewTab />
}
else if (_activeTab == Tab.Logs)
{
<LogsTab />
}
else
{
<SettingsTab />
}
</Rows>
</Panel>
</Rows>
@code {
private enum Tab { Overview, Logs, Settings }
private Tab _activeTab = Tab.Overview;
protected override void OnInitialized()
{
Registry.StateChanged += OnStateChanged;
LogSink.OnLog += OnNewLog;
}
private void SetTab(Tab tab)
{
_activeTab = tab;
StateHasChanged();
}
private void OnStateChanged()
{
InvokeAsync(StateHasChanged);
}
private void OnNewLog(LogEntry _)
{
if (_activeTab == Tab.Logs)
{
InvokeAsync(StateHasChanged);
}
}
public void Dispose()
{
Registry.StateChanged -= OnStateChanged;
LogSink.OnLog -= OnNewLog;
}
}

View File

@@ -0,0 +1,119 @@
@using LazyBear.MCP.Services.Logging
@inject InMemoryLogSink LogSink
@implements IDisposable
<Rows>
<Markup Content=" " />
@* Фильтр по модулю *@
<Columns>
<Markup Content="Filter: " />
<Select TItem="string"
Options="@_filterOptions"
Value="@_selectedFilter"
ValueChanged="@OnFilterChanged"
FocusOrder="10" />
</Columns>
<Markup Content=" " />
@{
var entries = GetFilteredEntries();
}
@if (entries.Count == 0)
{
<Markup Content="[grey]No log entries yet...[/]" />
}
else
{
@* Показываем последние 20 строк с прокруткой *@
<ViewHeightScrollable LinesToRender="20"
ScrollOffset="@_scrollOffset"
ScrollOffsetChanged="@(v => { _scrollOffset = v; })" >
<Rows>
@foreach (var entry in entries)
{
var levelColor = entry.Level switch
{
LogLevel.Error or LogLevel.Critical => Spectre.Console.Color.Red,
LogLevel.Warning => Spectre.Console.Color.Yellow,
LogLevel.Information => Spectre.Console.Color.White,
_ => Spectre.Console.Color.Grey
};
var levelTag = entry.Level switch
{
LogLevel.Error => "ERR",
LogLevel.Critical => "CRT",
LogLevel.Warning => "WRN",
LogLevel.Information => "INF",
LogLevel.Debug => "DBG",
_ => "TRC"
};
var time = entry.Timestamp.ToString("HH:mm:ss");
var cat = entry.ShortCategory.Length > 18
? entry.ShortCategory[..18]
: entry.ShortCategory.PadRight(18);
var msg = entry.Message.Length > 80
? entry.Message[..80] + "..."
: entry.Message;
<Columns>
<Markup Content="@($"[grey]{time}[/]")" />
<Markup Content="@($" {levelTag} ")" Foreground="@levelColor" />
<Markup Content="@($"[grey]{cat}[/]")" />
<Markup Content="@($" {msg}")" />
</Columns>
}
</Rows>
</ViewHeightScrollable>
}
</Rows>
@code {
private string _selectedFilter = "All";
private int _scrollOffset = 0;
private string[] _filterOptions = ["All", "Jira", "Kubernetes", "Confluence", "MCP", "System"];
private static readonly Dictionary<string, string?> FilterPrefixes = new()
{
["All"] = null,
["Jira"] = "LazyBear.MCP.Services.Jira",
["Kubernetes"] = "LazyBear.MCP.Services.Kubernetes",
["Confluence"] = "LazyBear.MCP.Services.Confluence",
["MCP"] = "ModelContextProtocol",
["System"] = "Microsoft"
};
private IReadOnlyList<LogEntry> GetFilteredEntries()
{
FilterPrefixes.TryGetValue(_selectedFilter, out var prefix);
return LogSink.GetEntries(prefix);
}
private void OnFilterChanged(string value)
{
_selectedFilter = value;
_scrollOffset = 0;
StateHasChanged();
}
protected override void OnInitialized()
{
LogSink.OnLog += HandleNewLog;
}
private void HandleNewLog(LogEntry _)
{
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
LogSink.OnLog -= HandleNewLog;
}
}

View File

@@ -0,0 +1,39 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
<Rows>
<Markup Content=" " />
@foreach (var module in Registry.GetModules())
{
var (active, total) = Registry.GetToolCounts(module.ModuleName);
var isEnabled = Registry.IsModuleEnabled(module.ModuleName);
var statusColor = isEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red;
var statusText = isEnabled ? "ENABLED" : "DISABLED";
var activeColor = active == total
? Spectre.Console.Color.Green
: (active == 0 ? Spectre.Console.Color.Red : Spectre.Console.Color.Yellow);
<Panel Title="@module.ModuleName"
BorderColor="@(isEnabled ? Spectre.Console.Color.Green3 : Spectre.Console.Color.Grey46)"
Expand="true">
<Columns>
<Rows>
<Columns>
<Markup Content="Status: " />
<Markup Content="@statusText" Foreground="@statusColor" />
</Columns>
<Columns>
<Markup Content="Tools: " />
<Markup Content="@($"{active}/{total} active")" Foreground="@activeColor" />
</Columns>
<Markup Content="@module.Description" Foreground="@Spectre.Console.Color.Grey" />
</Rows>
</Columns>
</Panel>
}
<Markup Content=" " />
<Markup Content="[grey]Go to Settings tab to toggle modules and tools[/]" />
</Rows>
@code {
}

View File

@@ -0,0 +1,42 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
<Rows>
<Markup Content=" " />
<Markup Content="[bold]Tool Registry — runtime enable/disable[/]" />
<Markup Content="[grey]Changes take effect immediately without restart[/]" />
<Markup Content=" " />
@{
int focusIdx = 20;
}
@foreach (var module in Registry.GetModules())
{
var moduleEnabled = Registry.IsModuleEnabled(module.ModuleName);
var moduleColor = moduleEnabled ? Spectre.Console.Color.Green : Spectre.Console.Color.Red;
var moduleName = module.ModuleName;
var capturedFocus = focusIdx++;
<Panel Title="@module.ModuleName" BorderColor="@moduleColor" Expand="true">
<Rows>
<Columns>
<TextButton Content="@(moduleEnabled ? "[green]■ MODULE ENABLED[/]" : "[red]□ MODULE DISABLED[/]")"
OnClick="@(() => Registry.ToggleModule(moduleName))"
BackgroundColor="@(moduleEnabled ? Spectre.Console.Color.DarkGreen : Spectre.Console.Color.DarkRed)"
FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@capturedFocus" />
<Markup Content="@($" {module.Description}")" Foreground="@Spectre.Console.Color.Grey" />
</Columns>
<Markup Content=" " />
<ToolButtonList Module="@module" StartFocusIdx="@focusIdx" />
</Rows>
</Panel>
focusIdx += module.ToolNames.Count;
<Markup Content=" " />
}
</Rows>
@code {
}

View File

@@ -0,0 +1,21 @@
@using LazyBear.MCP.Services.ToolRegistry
@inject ToolRegistryService Registry
@foreach (var (tool, idx) in Module.ToolNames.Select((t, i) => (t, i)))
{
var toolEnabled = Registry.IsToolEnabled(Module.ModuleName, tool);
var toolName = tool;
var moduleName = Module.ModuleName;
var fo = StartFocusIdx + idx;
<TextButton Content="@(toolEnabled ? $"[green]✓[/] {toolName}" : $"[grey]✗[/] {toolName}")"
OnClick="@(() => Registry.ToggleTool(moduleName, toolName))"
BackgroundColor="@(toolEnabled ? Spectre.Console.Color.Grey19 : Spectre.Console.Color.Grey7)"
FocusedColor="@Spectre.Console.Color.Yellow"
FocusOrder="@fo" />
}
@code {
[Parameter, EditorRequired] public IToolModule Module { get; set; } = null!;
[Parameter] public int StartFocusIdx { get; set; } = 100;
}

View File

@@ -0,0 +1,5 @@
@using Microsoft.AspNetCore.Components
@using RazorConsole.Components
@using RazorConsole.Core
@using RazorConsole.Core.Rendering
@using LazyBear.MCP.TUI.Components

View File

@@ -0,0 +1,69 @@
using LazyBear.MCP.Services.ToolRegistry;
using LazyBear.MCP.TUI.Components;
using Microsoft.Extensions.Hosting;
using RazorConsole.Core;
namespace LazyBear.MCP.TUI;
/// <summary>
/// Запускает RazorConsole TUI как IHostedService в отдельном потоке,
/// чтобы не блокировать ASP.NET Core pipeline.
/// </summary>
public sealed class TuiHostedService(IServiceProvider services, ILogger<TuiHostedService> logger) : IHostedService
{
private Thread? _tuiThread;
private CancellationTokenSource? _cts;
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = new CancellationTokenSource();
// Регистрируем все IToolModule-модули в ToolRegistryService
var registry = services.GetRequiredService<ToolRegistryService>();
foreach (var module in services.GetServices<IToolModule>())
{
registry.RegisterModule(module);
}
_tuiThread = new Thread(RunTui)
{
IsBackground = true,
Name = "RazorConsole-TUI"
};
_tuiThread.Start();
logger.LogInformation("TUI запущен в фоновом потоке");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_cts?.Cancel();
logger.LogInformation("TUI остановлен");
return Task.CompletedTask;
}
private void RunTui()
{
try
{
var host = Host.CreateDefaultBuilder()
.UseRazorConsole<App>(configure: configure =>
{
configure.ConfigureServices((_, svc) =>
{
// Пробрасываем ключевые Singleton из основного DI-контейнера в TUI-контейнер
svc.AddSingleton(services.GetRequiredService<ToolRegistryService>());
svc.AddSingleton(services.GetRequiredService<Services.Logging.InMemoryLogSink>());
});
})
.Build();
host.Run();
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка в потоке TUI");
}
}
}