From f707dceb9837d3e3b0109145acf9dd93282176d2 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 10 Jun 2026 00:20:45 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Dashboard=20Backend=20Proxy=20=E2=80=93?= =?UTF-8?q?=20OpenClaw=20Gateway=20Integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DashboardController: /api/dashboard/status, agents, operations, chat/send, chat/messages, queue - OpenClawGatewayClient: typed HttpClient mit Gateway tools/invoke - Dashboard DTOs: DashboardAgentInfo, ChatRequest, ChatResponse, FeedEntry, QueueItem - Gateway auth: Bearer-Password via Integrations:OpenClaw:Password - Gateway-Down → graceful degradation (HTTP 200, leere Daten) - Build: 0 errors, Tests: 3/3 passed --- backend/Controllers/DashboardController.cs | 208 ++++++++++++++++ backend/Models/Dashboard.cs | 47 ++++ backend/Program.cs | 7 + backend/Services/OpenClawGatewayClient.cs | 269 +++++++++++++++++++++ 4 files changed, 531 insertions(+) create mode 100644 backend/Controllers/DashboardController.cs create mode 100644 backend/Models/Dashboard.cs create mode 100644 backend/Services/OpenClawGatewayClient.cs diff --git a/backend/Controllers/DashboardController.cs b/backend/Controllers/DashboardController.cs new file mode 100644 index 0000000..f53a649 --- /dev/null +++ b/backend/Controllers/DashboardController.cs @@ -0,0 +1,208 @@ +using Microsoft.AspNetCore.Mvc; +using Nexus.Api.Models; +using Nexus.Api.Services; + +namespace Nexus.Api.Controllers; + +[ApiController] +[Route("api/dashboard")] +public class DashboardController(OpenClawGatewayClient gateway, ILogger logger) + : ControllerBase +{ + /// + /// Gateway health + session_status + subagents count. + /// Returns HTTP 200 even when gateway is down (gatewayOk: false). + /// + [HttpGet("status")] + public async Task GetStatus() + { + try + { + return await gateway.GetStatusAsync(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard status check failed"); + return new DashboardStatus(false, "Offline", 0, 0); + } + } + + /// + /// Returns all agents with their current status. + /// Combines sessions_list + sub_agents_list. + /// + [HttpGet("agents")] + public async Task> GetAgents() + { + try + { + return await gateway.GetAgentsAsync(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard agents fetch failed"); + return new List(); + } + } + + /// + /// Returns the latest assistant messages (operations/feed) from the Iris session. + /// Filtered to role == "assistant" — those are the work feed entries. + /// + [HttpGet("operations")] + public async Task> GetOperations([FromQuery] int limit = 20) + { + try + { + var messages = await gateway.GetSessionHistoryAsync("iris", Math.Clamp(limit, 1, 100)); + var feed = new List(); + + foreach (var msg in messages) + { + if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + continue; + + if (string.IsNullOrWhiteSpace(msg.Content)) + continue; + + // Parse timestamp for display-friendly "time ago" + var ts = ParseTimestamp(msg.Timestamp); + var timeAgo = FormatTimeAgo(ts); + + // Extract a short agent indicator and action from content + var (agent, action) = ExtractAgentAction(msg.Content); + + feed.Add(new FeedEntry(agent, action, msg.Timestamp, timeAgo)); + } + + return feed; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard operations fetch failed"); + return new List(); + } + } + + /// + /// Send a chat message to the Iris session. + /// + [HttpPost("chat/send")] + public async Task SendChat([FromBody] ChatRequest request) + { + if (string.IsNullOrWhiteSpace(request.Message)) + return new ChatResponse(false, null, "Message is required"); + + try + { + var sessionKey = string.IsNullOrWhiteSpace(request.SessionKey) + ? "iris" + : request.SessionKey.Trim(); + + return await gateway.SendChatMessageAsync(sessionKey, request.Message.Trim()); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard chat send failed"); + return new ChatResponse(false, null, "Gateway nicht erreichbar"); + } + } + + /// + /// Returns chat messages (user + assistant only, not tool messages). + /// + [HttpGet("chat/messages")] + public async Task> GetMessages( + [FromQuery] string? sessionKey, + [FromQuery] int limit = 50, + [FromQuery] int offset = 0) + { + try + { + var key = string.IsNullOrWhiteSpace(sessionKey) ? "iris" : sessionKey.Trim(); + var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset)); + + // Filter: only user and assistant messages (exclude tool/system) + return messages + .Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase) + || string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard messages fetch failed"); + return new List(); + } + } + + /// + /// Returns the cron queue / pending tasks. + /// + [HttpGet("queue")] + public async Task> GetQueue() + { + try + { + return await gateway.GetQueueAsync(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Dashboard queue fetch failed"); + return new List(); + } + } + + // ========== Helpers ========== + + private static DateTimeOffset ParseTimestamp(string timestamp) + { + if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt)) + return dt; + return DateTimeOffset.UtcNow; + } + + private static string FormatTimeAgo(DateTimeOffset ts) + { + var diff = DateTimeOffset.UtcNow - ts; + if (diff.TotalMinutes < 1) return "just now"; + if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago"; + if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago"; + if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago"; + return ts.ToString("MMM dd"); + } + + private static (string Agent, string Action) ExtractAgentAction(string content) + { + // Take first line or first ~80 chars as the action summary + var firstLine = content.Split('\n', 2)[0].Trim(); + var summary = firstLine.Length > 80 ? firstLine[..80] + "…" : firstLine; + + // Try to identify which agent this came from + var agent = "Iris"; + foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" }) + { + var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + var after = content[(idx + marker.Length)..].TrimStart(); + var end = after.IndexOfAny(['\n', '\r', ',', '.']); + var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim(); + if (!string.IsNullOrWhiteSpace(found) && found.Length < 30) + { + agent = found; + break; + } + } + } + + // Try to find agent name at the start in brackets like [Agent: Iris] + if (agent == "Iris") + { + var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]"); + if (bracketMatch.Success) + agent = bracketMatch.Groups[1].Value.Trim(); + } + + return (agent, summary); + } +} diff --git a/backend/Models/Dashboard.cs b/backend/Models/Dashboard.cs new file mode 100644 index 0000000..57e2094 --- /dev/null +++ b/backend/Models/Dashboard.cs @@ -0,0 +1,47 @@ +namespace Nexus.Api.Models; + +public sealed record DashboardAgentInfo( + string Id, + string Name, + string Role, + string Model, + bool IsActive, + string? CurrentTask +); + +public sealed record MessageEntry( + string Role, + string Content, + string Timestamp +); + +public sealed record ChatRequest( + string Message, + string? SessionKey +); + +public sealed record ChatResponse( + bool Ok, + string? Reply, + string? Error +); + +public sealed record FeedEntry( + string Agent, + string Action, + string Timestamp, + string Time +); + +public sealed record DashboardStatus( + bool GatewayOk, + string IrisStatus, + int ActiveAgents, + int PendingTasks +); + +public sealed record QueueItem( + string Id, + string Name, + string Status +); diff --git a/backend/Program.cs b/backend/Program.cs index 055ea63..8d11ccd 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -112,6 +112,13 @@ builder.Services.AddHttpClient("gateway", client => client.Timeout = TimeSpan.FromSeconds(5); }); +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"] + ?? "http://127.0.0.1:18789"); + client.Timeout = TimeSpan.FromSeconds(5); +}); + // --- Application Services --- builder.Services.AddTransient(); builder.Services.AddScoped(); diff --git a/backend/Services/OpenClawGatewayClient.cs b/backend/Services/OpenClawGatewayClient.cs new file mode 100644 index 0000000..e932e64 --- /dev/null +++ b/backend/Services/OpenClawGatewayClient.cs @@ -0,0 +1,269 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using Nexus.Api.Models; + +namespace Nexus.Api.Services; + +public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + private string? GetPassword() + { + var password = configuration["Integrations:OpenClaw:Password"]; + if (string.IsNullOrWhiteSpace(password)) + password = configuration["Integrations:OpenClaw:Token"]; + return string.IsNullOrWhiteSpace(password) ? null : password; + } + + private void ApplyAuth(HttpRequestMessage request) + { + var password = GetPassword(); + if (password is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", password); + } + + public async Task InvokeToolAsync(string tool, object? args = null) + { + try + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/tools/invoke"); + ApplyAuth(request); + + var body = new Dictionary { ["tool"] = tool }; + if (args is not null) + body["args"] = args; + + request.Content = JsonContent.Create(body); + + using var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + if (string.IsNullOrWhiteSpace(json)) + return null; + + return JsonNode.Parse(json); + } + catch + { + return null; + } + } + + public async Task> GetAgentsAsync() + { + var agents = new List(); + + // Get sessions list (active agents/sessions) + var sessionsResult = await InvokeToolAsync("sessions_list"); + if (sessionsResult is not null) + { + var sessions = sessionsResult.AsArray(); + if (sessions is not null) + { + foreach (var session in sessions) + { + if (session is null) continue; + var id = session["sessionKey"]?.GetValue() ?? session["id"]?.GetValue() ?? ""; + var name = session["name"]?.GetValue() ?? id; + var model = session["model"]?.GetValue() ?? ""; + var status = session["status"]?.GetValue() ?? ""; + var isActive = status.Equals("active", StringComparison.OrdinalIgnoreCase); + + agents.Add(new DashboardAgentInfo( + Id: id, + Name: name, + Role: DeriveRole(id), + Model: model, + IsActive: isActive, + CurrentTask: session["currentTask"]?.GetValue() + )); + } + } + } + + // Also get subagents list + var subagentsResult = await InvokeToolAsync("sub_agents_list"); + if (subagentsResult is not null && subagentsResult is JsonArray subArray) + { + foreach (var sub in subArray) + { + if (sub is null) continue; + var id = sub["id"]?.GetValue() ?? ""; + if (agents.Any(a => a.Id == id)) continue; + + var name = sub["name"]?.GetValue() ?? id; + var model = sub["model"]?.GetValue() ?? ""; + var status = sub["status"]?.GetValue() ?? ""; + var isActive = status.Equals("active", StringComparison.OrdinalIgnoreCase) || + status.Equals("running", StringComparison.OrdinalIgnoreCase); + + agents.Add(new DashboardAgentInfo( + Id: id, + Name: name, + Role: DeriveRole(id), + Model: model, + IsActive: isActive, + CurrentTask: sub["currentTask"]?.GetValue() + )); + } + } + + return agents.Count > 0 ? agents : new List(); + } + + public async Task> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0) + { + try + { + var result = await InvokeToolAsync("sessions_history", new + { + sessionKey, + limit, + offset + }); + + if (result is null) + return new List(); + + var messages = new List(); + var array = result as JsonArray ?? result.AsArray(); + if (array is null) return messages; + + foreach (var msg in array) + { + if (msg is null) continue; + var role = msg["role"]?.GetValue() ?? ""; + var content = msg["content"]?.GetValue() ?? ""; + var timestamp = msg["timestamp"]?.GetValue() + ?? msg["ts"]?.GetValue() + ?? msg["createdAt"]?.GetValue() + ?? DateTimeOffset.UtcNow.ToString("o"); + + messages.Add(new MessageEntry(role, content, timestamp)); + } + + return messages; + } + catch + { + return new List(); + } + } + + public async Task SendChatMessageAsync(string sessionKey, string message) + { + try + { + var result = await InvokeToolAsync("sessions_send", new + { + sessionKey, + message + }); + + if (result is null) + return new ChatResponse(false, null, "Gateway nicht erreichbar"); + + var ok = result["ok"]?.GetValue() ?? result["success"]?.GetValue() ?? false; + var reply = result["reply"]?.GetValue() + ?? result["response"]?.GetValue() + ?? result["content"]?.GetValue(); + var error = result["error"]?.GetValue(); + + return new ChatResponse(ok, reply, error); + } + catch (Exception ex) + { + return new ChatResponse(false, null, $"Fehler: {ex.Message}"); + } + } + + public async Task> GetQueueAsync() + { + try + { + var result = await InvokeToolAsync("cron_list"); + if (result is null) + return new List(); + + var items = new List(); + var array = result as JsonArray ?? result.AsArray(); + if (array is null) return items; + + foreach (var entry in array) + { + if (entry is null) continue; + var id = entry["id"]?.GetValue() ?? ""; + var name = entry["name"]?.GetValue() ?? id; + var status = entry["status"]?.GetValue() ?? "unknown"; + + items.Add(new QueueItem(id, name, status)); + } + + return items; + } + catch + { + return new List(); + } + } + + public async Task GetStatusAsync() + { + var gatewayOk = false; + var irisStatus = "Offline"; + var activeAgents = 0; + var pendingTasks = 0; + + try + { + // Check gateway health + using var pingRequest = new HttpRequestMessage(HttpMethod.Get, "/"); + ApplyAuth(pingRequest); + using var pingResponse = await httpClient.SendAsync(pingRequest); + gatewayOk = pingResponse.IsSuccessStatusCode; + + if (gatewayOk) + { + // Get session status + var sessionResult = await InvokeToolAsync("session_status"); + if (sessionResult is not null) + { + irisStatus = sessionResult["status"]?.GetValue() ?? "Active"; + } + + // Get agents for active count + var agents = await GetAgentsAsync(); + activeAgents = agents.Count(a => a.IsActive); + + // Get queue/cron for pending tasks + var queue = await GetQueueAsync(); + pendingTasks = queue.Count; + } + } + catch + { + gatewayOk = false; + } + + return new DashboardStatus(gatewayOk, irisStatus, activeAgents, pendingTasks); + } + + private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch + { + "iris" => "Orchestrator", + "programmer" => "Developer", + "reviewer" => "Reviewer", + "architekt" => "Architect", + "main" => "Assistant", + _ => "Custom" + }; +}