using Nexus.Api.Models; namespace Nexus.Api.Services; public sealed class DashboardService( IOpenClawGatewayClient gateway, IOpenClawControlService openClaw, IOpenClawChatService chat, ITaskService taskService, ILogger logger) : IDashboardService { public async Task GetStatusAsync() { var connection = openClaw.GetConnection(); var tasks = await openClaw.GetTasksAsync(200); var sessions = await openClaw.GetSessionsAsync(200); return new DashboardStatus( connection.Connected, connection.Connected ? "Online" : "Offline", sessions.Items.Count(session => session.Status is "running" or "active"), tasks.Items.Count(task => task.Status is "queued" or "running")); } public async Task> GetAgentsAsync() { var agentsTask = openClaw.GetAgentsAsync(); var sessionsTask = openClaw.GetSessionsAsync(500); var tasksTask = openClaw.GetTasksAsync(500); await Task.WhenAll(agentsTask, sessionsTask, tasksTask); var agents = agentsTask.Result; var sessions = sessionsTask.Result; var tasks = tasksTask.Result; return agents.Items.Select(agent => { var session = sessions.Items .Where(item => string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(item => item.UpdatedAt) .FirstOrDefault(); var task = tasks.Items .Where(item => string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrWhiteSpace(item.SessionKey) && string.Equals(item.SessionKey, session?.Key, StringComparison.Ordinal))) .Where(item => item.Status is "queued" or "running" or "active") .OrderByDescending(item => item.UpdatedAt ?? item.StartedAt ?? item.CreatedAt) .FirstOrDefault(); var active = session?.Status is "running" or "active"; return new DashboardAgentInfo( Id: agent.Id, Name: agent.Name, Role: DeriveRole(agent.Id), Model: session?.Model ?? agent.Model ?? "openclaw/default", IsActive: active, CurrentTask: task?.Title ?? (active ? session?.Title : null), Description: agent.Description, Tags: BuildAgentTags(agent.Id), Progress: task?.Progress, Workload: sessions.Items.Count(item => string.Equals(item.AgentId, agent.Id, StringComparison.OrdinalIgnoreCase) && item.Status is "running" or "active"), Goal: null, RoleBadge: DeriveRoleBadge(agent.Id), StatusLabel: active ? "Working" : "Ready", StatusKind: active ? "working" : "ready", StatusDetail: session is null ? "No Gateway session reported." : session.Title, Elapsed: null, Think: null, Next: null, TotalTokens: session?.TotalTokens, CostUsd: null, TelemetryAt: session?.UpdatedAt); }).ToList(); } public async Task> GetOperationsAsync(int limit, string? agentFilter) { var activity = await openClaw.GetActivityAsync(Math.Clamp(limit, 1, 100)); var entries = activity.Items; if (!string.IsNullOrWhiteSpace(agentFilter)) entries = entries .Where(item => string.Equals(item.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)) .ToArray(); return entries.Select(item => new FeedEntry( item.AgentId ?? item.Actor ?? "OpenClaw", item.Message, item.OccurredAt?.ToString("O") ?? activity.CheckedAt.ToString("O"), item.OccurredAt?.ToLocalTime().ToString("HH:mm") ?? "--:--", item.AgentId, item.EventType)).ToList(); } public async Task SendChatAsync(string agentId, string message) { try { var context = OpenClawInvocationContextFactory.Create( actor: "nexus-dashboard", idempotencyKey: null, correlationId: null, traceParent: null); var result = await chat.SendAsync( message, $"nexus-dashboard-{agentId.ToLowerInvariant()}", agentId, new OpenClawInvocationMetadata( context.IdempotencyKey, context.CorrelationId, context.Actor, context.TraceParent), CancellationToken.None); return new ChatResponse(true, result.Content, null); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard chat send failed"); return new ChatResponse(false, null, "OpenClaw chat endpoint is not enabled or reachable."); } } public async Task> GetMessagesAsync(string? sessionKey, int limit, int offset) { try { var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim(); var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset)); 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 []; } } public async Task> GetQueueAsync(CancellationToken ct) { try { var cronTask = openClaw.GetCronJobsAsync(200, ct); var tasksTask = taskService.GetOpenAsync(ct); await Task.WhenAll(cronTask, tasksTask); var merged = cronTask.Result.Items.Select(job => new QueueItem( job.Id, job.Name, job.Status, "medium", "cron", FormatWaitTime(job.NextRunAt))).ToList(); foreach (var t in tasksTask.Result) { merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--")); } return merged .OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99)) .ToList(); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard queue fetch failed"); return []; } } public async Task GetGatewayInfoAsync(CancellationToken ct) { await Task.CompletedTask; var connection = openClaw.GetConnection(); var versionStatus = !connection.Connected ? "error" : !connection.VersionPinned ? "unpinned" : connection.GatewayVersion is null ? "missing" : connection.VersionMatches ? "matched" : "drift"; return new GatewayRuntimeInfo( connection.Connected, connection.Endpoint, connection.GatewayVersion, connection.RequiredVersion, connection.VersionPinned, connection.VersionMatches, versionStatus, connection.CheckedAt, connection.Message, connection.Recovery); } public async Task DeleteQueueItemAsync(string id, string? source, CancellationToken ct) { if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)) return new QueueDeleteResult(QueueDeleteOutcome.Ignored); if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-")) { if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId); if (!Guid.TryParse(id["task-".Length..], out var guid)) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId); var result = await taskService.CompleteViaQueueAsync(guid, ct); return result.Outcome switch { TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound), _ => new QueueDeleteResult(QueueDeleteOutcome.Deleted) }; } return new QueueDeleteResult(QueueDeleteOutcome.NotFound); } public async Task CycleQueuePriorityAsync(string id, CancellationToken ct) { if (!id.StartsWith("task-")) return new QueuePriorityResult(QueuePriorityOutcome.Ignored); if (!Guid.TryParse(id["task-".Length..], out var guid)) return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId); var result = await taskService.CyclePriorityAsync(guid, ct); return result.Outcome switch { TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound), _ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority) }; } public async Task GetAgentModelAsync(string agentId) { var sessions = await openClaw.GetSessionsAsync(500); var session = sessions.Items .Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase)) .OrderByDescending(item => item.UpdatedAt) .FirstOrDefault(); return session?.Model is null ? null : new AgentModelInfo(session.Model, session.Provider ?? "unknown"); } public async Task SetAgentModelAsync(string agentId, string model) { var result = await openClaw.PatchSessionModelAsync( $"agent:{agentId}:main", model); return result.Ok; } public async Task> GetAgentActivityAsync(string agentId, int limit) { var response = await openClaw.GetActivityAsync(Math.Clamp(limit * 5, 1, 100)); return response.Items .Where(item => string.Equals(item.AgentId, agentId, StringComparison.OrdinalIgnoreCase)) .Take(Math.Clamp(limit, 1, 20)) .Select(item => new AgentActivityEntry( FormatTimeAgo(item.OccurredAt), item.Message, item.OccurredAt ?? response.CheckedAt, item.Source)) .ToList(); } public async Task> GetAvailableModelsAsync(CancellationToken ct) { var models = await openClaw.GetModelsAsync(ct); return models.Items .Where(model => !string.IsNullOrWhiteSpace(model.Id)) .Select(model => new ModelOption( model.Id, string.IsNullOrWhiteSpace(model.Name) ? model.Id : model.Name, model.Provider)) .DistinctBy(model => model.Id, StringComparer.OrdinalIgnoreCase) .OrderBy(model => model.Provider, StringComparer.OrdinalIgnoreCase) .ThenBy(model => model.Name, StringComparer.OrdinalIgnoreCase) .ToList(); } private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch { "high" or "critical" or "urgent" => "high", "low" or "minor" => "low", _ => "medium" }; private static readonly Dictionary PriorityOrder = new(StringComparer.OrdinalIgnoreCase) { ["high"] = 0, ["medium"] = 1, ["low"] = 2 }; private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "Orchestrator", "programmer" => "Developer", "reviewer" => "Reviewer", "architekt" => "Architect", "researcher" => "Researcher", "executor" => "Executor", "main" => "Assistant", _ => "Agent" }; private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch { "iris" => "badge-violet", "reviewer" => "badge-amber", "executor" => "badge-green", _ => "badge-blue" }; private static string[] BuildAgentTags(string agentId) => agentId.ToLowerInvariant() switch { "iris" => ["orchestration", "delegation", "approvals"], "programmer" => ["code", "build", "test"], "reviewer" => ["review", "quality", "security"], "architekt" => ["architecture", "infrastructure"], "researcher" => ["research", "analysis"], "executor" => ["execution", "operations"], _ => ["openclaw"] }; private static string FormatWaitTime(DateTimeOffset? nextRunAt) { if (nextRunAt is null) return "--"; var remaining = nextRunAt.Value - DateTimeOffset.UtcNow; if (remaining <= TimeSpan.Zero) return "now"; if (remaining.TotalMinutes < 1) return "<1m"; if (remaining.TotalHours < 1) return $"{(int)remaining.TotalMinutes}m"; if (remaining.TotalDays < 1) return $"{(int)remaining.TotalHours}h"; return $"{(int)remaining.TotalDays}d"; } private static string FormatTimeAgo(DateTimeOffset? timestamp) { if (timestamp is null) return "unknown"; var elapsed = DateTimeOffset.UtcNow - timestamp.Value; if (elapsed.TotalMinutes < 1) return "now"; if (elapsed.TotalHours < 1) return $"{(int)elapsed.TotalMinutes}m"; if (elapsed.TotalDays < 1) return $"{(int)elapsed.TotalHours}h"; return $"{(int)elapsed.TotalDays}d"; } }