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:
@@ -4,15 +4,17 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Включаем Razor-компоненты (Blazor-стиль) без MVC -->
|
||||
<AddRazorSupportForMvc>false</AddRazorSupportForMvc>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="KubernetesClient" Version="19.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||
<PackageReference Include="Polly" Version="8.4.2" />
|
||||
<PackageReference Include="RazorConsole.Core" Version="0.5.0" />
|
||||
<PackageReference Include="RestSharp" Version="112.0.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
using LazyBear.MCP.Services.Confluence;
|
||||
using LazyBear.MCP.Services.Confluence;
|
||||
using LazyBear.MCP.Services.Jira;
|
||||
using LazyBear.MCP.Services.Kubernetes;
|
||||
using LazyBear.MCP.Services.Logging;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using LazyBear.MCP.TUI;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ── InMemoryLogSink: регистрируем Singleton и кастомный логгер ───────────────
|
||||
var logSink = new InMemoryLogSink();
|
||||
builder.Services.AddSingleton(logSink);
|
||||
builder.Logging.AddProvider(new InMemoryLoggerProvider(logSink));
|
||||
|
||||
// ── MCP-провайдеры ───────────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<K8sClientProvider>();
|
||||
builder.Services.AddSingleton<JiraClientProvider>();
|
||||
builder.Services.AddSingleton<ConfluenceClientProvider>();
|
||||
|
||||
// ── ToolRegistry ─────────────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<ToolRegistryService>();
|
||||
|
||||
// ── Модули инструментов (generic: добавь новый IToolModule — он появится в TUI)
|
||||
builder.Services.AddSingleton<IToolModule, JiraToolModule>();
|
||||
builder.Services.AddSingleton<IToolModule, KubernetesToolModule>();
|
||||
builder.Services.AddSingleton<IToolModule, ConfluenceToolModule>();
|
||||
|
||||
// ── MCP-сервер ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithToolsFromAssembly();
|
||||
|
||||
// ── TUI как фоновый сервис ───────────────────────────────────────────────────
|
||||
builder.Services.AddHostedService<TuiHostedService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapMcp();
|
||||
|
||||
app.Run("http://localhost:5000");
|
||||
app.Run("http://localhost:5000");
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using ModelContextProtocol.Server;
|
||||
using RestSharp;
|
||||
|
||||
namespace LazyBear.MCP.Services.Confluence;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, IConfiguration configuration)
|
||||
public sealed class ConfluencePagesTools(
|
||||
ConfluenceClientProvider provider,
|
||||
IConfiguration configuration,
|
||||
ToolRegistryService registry)
|
||||
{
|
||||
private readonly RestClient? _client = provider.Client;
|
||||
private readonly string? _clientInitializationError = provider.InitializationError;
|
||||
@@ -15,11 +19,26 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
|
||||
private readonly string _username = configuration["Confluence:Username"] ?? string.Empty;
|
||||
private readonly string _spaceKey = configuration["Confluence:SpaceKey"] ?? string.Empty;
|
||||
|
||||
private const string ModuleName = "Confluence";
|
||||
|
||||
private bool TryCheckEnabled(string toolName, out string error)
|
||||
{
|
||||
if (!registry.IsToolEnabled(ModuleName, toolName))
|
||||
{
|
||||
error = $"Инструмент '{toolName}' модуля Confluence отключён в TUI.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Получить страницу Confluence по ID")]
|
||||
public async Task<string> GetPage(
|
||||
[Description("ID страницы Confluence")] string pageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetPage", out var enabledError)) return enabledError;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pageId))
|
||||
{
|
||||
return "ID страницы Confluence не задан.";
|
||||
@@ -64,6 +83,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
|
||||
[Description("Максимум страниц в ответе")] int maxResults = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("SearchPages", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -122,6 +143,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
|
||||
[Description("ID родительской страницы. Опционально")] string? parentId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("CreatePage", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -188,6 +211,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
|
||||
[Description("Новое название страницы. Если пусто, не меняется")] string? title = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("UpdatePage", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -250,6 +275,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
|
||||
[Description("ID страницы для удаления")] string pageId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("DeletePage", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -279,6 +306,8 @@ public sealed class ConfluencePagesTools(ConfluenceClientProvider provider, ICon
|
||||
[Description("Ключ пространства")] string spaceKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetSpace", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
|
||||
19
LazyBear.MCP/Services/Confluence/ConfluenceToolModule.cs
Normal file
19
LazyBear.MCP/Services/Confluence/ConfluenceToolModule.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
|
||||
namespace LazyBear.MCP.Services.Confluence;
|
||||
|
||||
public sealed class ConfluenceToolModule : IToolModule
|
||||
{
|
||||
public string ModuleName => "Confluence";
|
||||
public string Description => "Confluence: страницы, пространства, поиск";
|
||||
|
||||
public IReadOnlyList<string> ToolNames =>
|
||||
[
|
||||
"GetPage",
|
||||
"SearchPages",
|
||||
"CreatePage",
|
||||
"UpdatePage",
|
||||
"DeletePage",
|
||||
"GetSpace"
|
||||
];
|
||||
}
|
||||
@@ -1,23 +1,42 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using ModelContextProtocol.Server;
|
||||
using RestSharp;
|
||||
|
||||
namespace LazyBear.MCP.Services.Jira;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration configuration)
|
||||
public sealed class JiraIssueTools(
|
||||
JiraClientProvider provider,
|
||||
IConfiguration configuration,
|
||||
ToolRegistryService registry)
|
||||
{
|
||||
private readonly RestClient? _client = provider.Client;
|
||||
private readonly string? _clientInitializationError = provider.InitializationError;
|
||||
private readonly string _token = configuration["Jira:Token"] ?? string.Empty;
|
||||
private readonly string _defaultProject = configuration["Jira:Project"] ?? string.Empty;
|
||||
|
||||
private const string ModuleName = "Jira";
|
||||
|
||||
private bool TryCheckEnabled(string toolName, out string error)
|
||||
{
|
||||
if (!registry.IsToolEnabled(ModuleName, toolName))
|
||||
{
|
||||
error = $"Инструмент '{toolName}' модуля Jira отключён в TUI.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Получить задачу Jira по ключу")]
|
||||
public async Task<string> GetIssue(
|
||||
[Description("Ключ задачи, например PROJ-123")] string issueKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetIssue", out var enabledError)) return enabledError;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(issueKey))
|
||||
{
|
||||
return "Ключ задачи Jira не задан.";
|
||||
@@ -60,6 +79,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
||||
[Description("Максимум задач в ответе")] int maxResults = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListIssues", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -118,6 +139,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
||||
[Description("Описание задачи")] string? description = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("CreateIssue", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -190,6 +213,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
||||
[Description("Новое описание")] string? description = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("UpdateIssue", out var enabledError)) return enabledError;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(summary) && string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return $"Нет полей для обновления задачи '{issueKey}'.";
|
||||
@@ -256,6 +281,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
||||
[Description("Ключ задачи")] string issueKey,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetIssueStatuses", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -301,6 +328,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
||||
[Description("Максимум комментариев")] int limit = 20,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListIssueComments", out var enabledError)) return enabledError;
|
||||
|
||||
if (!TryGetClient(out var client, out var error))
|
||||
{
|
||||
return error;
|
||||
@@ -349,6 +378,8 @@ public sealed class JiraIssueTools(JiraClientProvider provider, IConfiguration c
|
||||
[Description("Текст комментария")] string body,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("AddComment", out var enabledError)) return enabledError;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return "Текст комментария Jira не задан.";
|
||||
|
||||
20
LazyBear.MCP/Services/Jira/JiraToolModule.cs
Normal file
20
LazyBear.MCP/Services/Jira/JiraToolModule.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
|
||||
namespace LazyBear.MCP.Services.Jira;
|
||||
|
||||
public sealed class JiraToolModule : IToolModule
|
||||
{
|
||||
public string ModuleName => "Jira";
|
||||
public string Description => "Jira: задачи, комментарии, переходы статусов";
|
||||
|
||||
public IReadOnlyList<string> ToolNames =>
|
||||
[
|
||||
"GetIssue",
|
||||
"ListIssues",
|
||||
"CreateIssue",
|
||||
"UpdateIssue",
|
||||
"GetIssueStatuses",
|
||||
"ListIssueComments",
|
||||
"AddComment"
|
||||
];
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
using System.ComponentModel;
|
||||
using k8s;
|
||||
using k8s.Autorest;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace LazyBear.MCP.Services.Kubernetes;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sConfigTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
|
||||
public sealed class K8sConfigTools(
|
||||
K8sClientProvider clientProvider,
|
||||
IConfiguration configuration,
|
||||
ToolRegistryService registry,
|
||||
ILogger<K8sConfigTools>? logger = null)
|
||||
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||
{
|
||||
private const int MaxSecretKeyLimit = 100;
|
||||
|
||||
@@ -15,6 +21,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListConfigMaps", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
if (!TryGetClient(out var client, out var clientError))
|
||||
@@ -52,6 +60,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetConfigMapData", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
@@ -83,6 +93,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListSecrets", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
if (!TryGetClient(out var client, out var clientError))
|
||||
@@ -120,6 +132,8 @@ public sealed class K8sConfigTools(K8sClientProvider clientProvider, IConfigurat
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetSecretKeys", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
|
||||
@@ -3,12 +3,18 @@ using System.Text.Json;
|
||||
using k8s;
|
||||
using k8s.Autorest;
|
||||
using k8s.Models;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace LazyBear.MCP.Services.Kubernetes;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sDeploymentTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
|
||||
public sealed class K8sDeploymentTools(
|
||||
K8sClientProvider clientProvider,
|
||||
IConfiguration configuration,
|
||||
ToolRegistryService registry,
|
||||
ILogger<K8sDeploymentTools>? logger = null)
|
||||
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||
{
|
||||
private const int MinReplicas = 0;
|
||||
private const int MaxReplicas = 100;
|
||||
@@ -18,6 +24,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListDeployments", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
if (!TryGetClient(out var client, out var clientError))
|
||||
@@ -58,6 +66,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ScaleDeployment", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
|
||||
@@ -95,6 +105,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetRolloutStatus", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
@@ -126,6 +138,8 @@ public sealed class K8sDeploymentTools(K8sClientProvider clientProvider, IConfig
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("RestartDeployment", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
using System.ComponentModel;
|
||||
using k8s;
|
||||
using k8s.Autorest;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace LazyBear.MCP.Services.Kubernetes;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sNetworkTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
|
||||
public sealed class K8sNetworkTools(
|
||||
K8sClientProvider clientProvider,
|
||||
IConfiguration configuration,
|
||||
ToolRegistryService registry,
|
||||
ILogger<K8sNetworkTools>? logger = null)
|
||||
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||
{
|
||||
[McpServerTool, Description("Список service в namespace")]
|
||||
public async Task<string> ListServices(
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListServices", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
if (!TryGetClient(out var client, out var clientError))
|
||||
@@ -55,6 +63,8 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetServiceDetails", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
@@ -90,6 +100,8 @@ public sealed class K8sNetworkTools(K8sClientProvider clientProvider, IConfigura
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListIngresses", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
if (!TryGetClient(out var client, out var clientError))
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
using System.ComponentModel;
|
||||
using k8s;
|
||||
using k8s.Autorest;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace LazyBear.MCP.Services.Kubernetes;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguration configuration, ILogger<K8sPodsTools>? logger = null) : KubernetesToolsBase(clientProvider, configuration, logger)
|
||||
public sealed class K8sPodsTools(
|
||||
K8sClientProvider clientProvider,
|
||||
IConfiguration configuration,
|
||||
ToolRegistryService registry,
|
||||
ILogger<K8sPodsTools>? logger = null)
|
||||
: KubernetesToolsBase(clientProvider, configuration, registry, logger)
|
||||
{
|
||||
private const int MaxTailLines = 10;
|
||||
private const int MinTailLines = 10;
|
||||
@@ -16,6 +22,8 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("ListPods", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
if (!TryGetClient(out var client, out var clientError))
|
||||
@@ -55,6 +63,8 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
|
||||
[Description("Namespace Kubernetes")] string? @namespace = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetPodStatus", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
var ns = ResolveNamespace(@namespace);
|
||||
@@ -90,6 +100,8 @@ public sealed class K8sPodsTools(K8sClientProvider clientProvider, IConfiguratio
|
||||
int? tailLines = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!TryCheckEnabled("GetPodLogs", out var enabledError)) return enabledError;
|
||||
|
||||
ValidateResourceName(name, nameof(name));
|
||||
ValidateNamespace(@namespace ?? _defaultNamespace, nameof(@namespace));
|
||||
|
||||
|
||||
31
LazyBear.MCP/Services/Kubernetes/KubernetesToolModule.cs
Normal file
31
LazyBear.MCP/Services/Kubernetes/KubernetesToolModule.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
|
||||
namespace LazyBear.MCP.Services.Kubernetes;
|
||||
|
||||
public sealed class KubernetesToolModule : IToolModule
|
||||
{
|
||||
public string ModuleName => "Kubernetes";
|
||||
public string Description => "Kubernetes: поды, деплойменты, сервисы, конфиги";
|
||||
|
||||
public IReadOnlyList<string> ToolNames =>
|
||||
[
|
||||
// Pods
|
||||
"ListPods",
|
||||
"GetPodStatus",
|
||||
"GetPodLogs",
|
||||
// Deployments
|
||||
"ListDeployments",
|
||||
"ScaleDeployment",
|
||||
"GetRolloutStatus",
|
||||
"RestartDeployment",
|
||||
// Network
|
||||
"ListServices",
|
||||
"GetServiceDetails",
|
||||
"ListIngresses",
|
||||
// Config
|
||||
"ListConfigMaps",
|
||||
"GetConfigMapData",
|
||||
"ListSecrets",
|
||||
"GetSecretKeys"
|
||||
];
|
||||
}
|
||||
@@ -1,18 +1,36 @@
|
||||
using System.ComponentModel;
|
||||
using System.Text.RegularExpressions;
|
||||
using k8s;
|
||||
using LazyBear.MCP.Services.ToolRegistry;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace LazyBear.MCP.Services.Kubernetes;
|
||||
|
||||
[McpServerToolType]
|
||||
public abstract class KubernetesToolsBase(K8sClientProvider clientProvider, IConfiguration configuration, ILogger? logger = null)
|
||||
public abstract class KubernetesToolsBase(
|
||||
K8sClientProvider clientProvider,
|
||||
IConfiguration configuration,
|
||||
ToolRegistryService registry,
|
||||
ILogger? logger = null)
|
||||
{
|
||||
protected readonly IKubernetes? _client = clientProvider.Client;
|
||||
protected readonly string? _clientInitializationError = clientProvider.InitializationError;
|
||||
protected readonly string _defaultNamespace = configuration["Kubernetes:DefaultNamespace"] ?? "default";
|
||||
protected readonly ILogger? _logger = logger;
|
||||
protected readonly ToolRegistryService _registry = registry;
|
||||
|
||||
protected const string K8sModuleName = "Kubernetes";
|
||||
|
||||
protected bool TryCheckEnabled(string toolName, out string error)
|
||||
{
|
||||
if (!_registry.IsToolEnabled(K8sModuleName, toolName))
|
||||
{
|
||||
error = $"Инструмент '{toolName}' модуля Kubernetes отключён в TUI.";
|
||||
return false;
|
||||
}
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected static readonly Regex NamespaceRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
protected static readonly Regex ResourceNameRegex = new(@"^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
|
||||
47
LazyBear.MCP/Services/Logging/InMemoryLogSink.cs
Normal file
47
LazyBear.MCP/Services/Logging/InMemoryLogSink.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LazyBear.MCP.Services.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton, хранящий последние <see cref="Capacity"/> записей лога в памяти.
|
||||
/// Безопасен для многопоточной записи.
|
||||
/// </summary>
|
||||
public sealed class InMemoryLogSink
|
||||
{
|
||||
public const int Capacity = 500;
|
||||
|
||||
private readonly ConcurrentQueue<LogEntry> _entries = new();
|
||||
|
||||
/// <summary>Событие вызывается при добавлении новой записи.</summary>
|
||||
public event Action<LogEntry>? OnLog;
|
||||
|
||||
public void Add(LogEntry entry)
|
||||
{
|
||||
_entries.Enqueue(entry);
|
||||
|
||||
// Обрезаем старые записи
|
||||
while (_entries.Count > Capacity)
|
||||
{
|
||||
_entries.TryDequeue(out _);
|
||||
}
|
||||
|
||||
OnLog?.Invoke(entry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает снимок всех записей. Опциональный фильтр по категории-префиксу.
|
||||
/// </summary>
|
||||
public IReadOnlyList<LogEntry> GetEntries(string? categoryPrefix = null)
|
||||
{
|
||||
var all = _entries.ToArray();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(categoryPrefix))
|
||||
{
|
||||
return all;
|
||||
}
|
||||
|
||||
return Array.FindAll(all, e => e.Category.StartsWith(categoryPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public void Clear() => _entries.Clear();
|
||||
}
|
||||
42
LazyBear.MCP/Services/Logging/InMemoryLoggerProvider.cs
Normal file
42
LazyBear.MCP/Services/Logging/InMemoryLoggerProvider.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace LazyBear.MCP.Services.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// ILoggerProvider, направляющий все логи в <see cref="InMemoryLogSink"/>.
|
||||
/// </summary>
|
||||
[ProviderAlias("InMemory")]
|
||||
public sealed class InMemoryLoggerProvider(InMemoryLogSink sink) : ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName) =>
|
||||
new InMemoryLogger(sink, categoryName);
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
internal sealed class InMemoryLogger(InMemoryLogSink sink, string category) : ILogger
|
||||
{
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel)) return;
|
||||
|
||||
var message = formatter(state, exception);
|
||||
var entry = new LogEntry(
|
||||
DateTimeOffset.Now,
|
||||
logLevel,
|
||||
category,
|
||||
message,
|
||||
exception?.ToString());
|
||||
|
||||
sink.Add(entry);
|
||||
}
|
||||
}
|
||||
14
LazyBear.MCP/Services/Logging/LogEntry.cs
Normal file
14
LazyBear.MCP/Services/Logging/LogEntry.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace LazyBear.MCP.Services.Logging;
|
||||
|
||||
public sealed record LogEntry(
|
||||
DateTimeOffset Timestamp,
|
||||
LogLevel Level,
|
||||
string Category,
|
||||
string Message,
|
||||
string? Exception = null)
|
||||
{
|
||||
/// <summary>Короткое имя категории (последний сегмент namespace)</summary>
|
||||
public string ShortCategory => Category.Contains('.')
|
||||
? Category[(Category.LastIndexOf('.') + 1)..]
|
||||
: Category;
|
||||
}
|
||||
17
LazyBear.MCP/Services/ToolRegistry/IToolModule.cs
Normal file
17
LazyBear.MCP/Services/ToolRegistry/IToolModule.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace LazyBear.MCP.Services.ToolRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// Описывает группу MCP-инструментов (один интеграционный модуль).
|
||||
/// Реализуйте этот интерфейс для регистрации новых модулей.
|
||||
/// </summary>
|
||||
public interface IToolModule
|
||||
{
|
||||
/// <summary>Уникальное имя модуля (Jira, Kubernetes, Confluence, …)</summary>
|
||||
string ModuleName { get; }
|
||||
|
||||
/// <summary>Имена всех инструментов, входящих в модуль.</summary>
|
||||
IReadOnlyList<string> ToolNames { get; }
|
||||
|
||||
/// <summary>Человекочитаемое описание модуля для TUI.</summary>
|
||||
string Description { get; }
|
||||
}
|
||||
96
LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs
Normal file
96
LazyBear.MCP/Services/ToolRegistry/ToolRegistryService.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace LazyBear.MCP.Services.ToolRegistry;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton. Хранит runtime-состояние включённости модулей и отдельных инструментов.
|
||||
/// Thread-safe. Уведомляет подписчиков при любом изменении.
|
||||
/// </summary>
|
||||
public sealed class ToolRegistryService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, bool> _moduleEnabled = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, bool> _toolEnabled = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly List<IToolModule> _modules = [];
|
||||
private readonly Lock _modulesLock = new();
|
||||
|
||||
/// <summary>Вызывается при любом изменении состояния.</summary>
|
||||
public event Action? StateChanged;
|
||||
|
||||
// ── Регистрация ──────────────────────────────────────────────────────────
|
||||
|
||||
public void RegisterModule(IToolModule module)
|
||||
{
|
||||
lock (_modulesLock)
|
||||
{
|
||||
if (_modules.Any(m => string.Equals(m.ModuleName, module.ModuleName, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
|
||||
_modules.Add(module);
|
||||
}
|
||||
|
||||
_moduleEnabled.TryAdd(module.ModuleName, true);
|
||||
|
||||
foreach (var tool in module.ToolNames)
|
||||
{
|
||||
_toolEnabled.TryAdd(MakeKey(module.ModuleName, tool), true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Запросы состояния ────────────────────────────────────────────────────
|
||||
|
||||
public IReadOnlyList<IToolModule> GetModules()
|
||||
{
|
||||
lock (_modulesLock)
|
||||
{
|
||||
return _modules.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsModuleEnabled(string moduleName) =>
|
||||
_moduleEnabled.GetValueOrDefault(moduleName, true);
|
||||
|
||||
public bool IsToolEnabled(string moduleName, string toolName) =>
|
||||
IsModuleEnabled(moduleName) &&
|
||||
_toolEnabled.GetValueOrDefault(MakeKey(moduleName, toolName), true);
|
||||
|
||||
// ── Переключение ─────────────────────────────────────────────────────────
|
||||
|
||||
public void SetModuleEnabled(string moduleName, bool enabled)
|
||||
{
|
||||
_moduleEnabled[moduleName] = enabled;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void SetToolEnabled(string moduleName, string toolName, bool enabled)
|
||||
{
|
||||
_toolEnabled[MakeKey(moduleName, toolName)] = enabled;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void ToggleModule(string moduleName) =>
|
||||
SetModuleEnabled(moduleName, !IsModuleEnabled(moduleName));
|
||||
|
||||
public void ToggleTool(string moduleName, string toolName) =>
|
||||
SetToolEnabled(moduleName, toolName, !IsToolEnabled(moduleName, toolName));
|
||||
|
||||
// ── Счётчики для Overview ─────────────────────────────────────────────────
|
||||
|
||||
public (int Active, int Total) GetToolCounts(string moduleName)
|
||||
{
|
||||
lock (_modulesLock)
|
||||
{
|
||||
var module = _modules.FirstOrDefault(m =>
|
||||
string.Equals(m.ModuleName, moduleName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (module is null) return (0, 0);
|
||||
|
||||
var total = module.ToolNames.Count;
|
||||
var active = module.ToolNames.Count(t => IsToolEnabled(moduleName, t));
|
||||
return (active, total);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private static string MakeKey(string module, string tool) => $"{module}::{tool}";
|
||||
}
|
||||
81
LazyBear.MCP/TUI/Components/App.razor
Normal file
81
LazyBear.MCP/TUI/Components/App.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
119
LazyBear.MCP/TUI/Components/LogsTab.razor
Normal file
119
LazyBear.MCP/TUI/Components/LogsTab.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
39
LazyBear.MCP/TUI/Components/OverviewTab.razor
Normal file
39
LazyBear.MCP/TUI/Components/OverviewTab.razor
Normal 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 {
|
||||
}
|
||||
42
LazyBear.MCP/TUI/Components/SettingsTab.razor
Normal file
42
LazyBear.MCP/TUI/Components/SettingsTab.razor
Normal 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 {
|
||||
}
|
||||
21
LazyBear.MCP/TUI/Components/ToolButtonList.razor
Normal file
21
LazyBear.MCP/TUI/Components/ToolButtonList.razor
Normal 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;
|
||||
}
|
||||
5
LazyBear.MCP/TUI/Components/_Imports.razor
Normal file
5
LazyBear.MCP/TUI/Components/_Imports.razor
Normal file
@@ -0,0 +1,5 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using RazorConsole.Components
|
||||
@using RazorConsole.Core
|
||||
@using RazorConsole.Core.Rendering
|
||||
@using LazyBear.MCP.TUI.Components
|
||||
69
LazyBear.MCP/TUI/TuiHostedService.cs
Normal file
69
LazyBear.MCP/TUI/TuiHostedService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user