using Microsoft.AspNetCore.Mvc; using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Repositories; using Nexus.Api.Services; namespace Nexus.Api.Controllers; [ApiController] [Route("api/dashboard")] public class DashboardController( OpenClawGatewayClient gateway, ITaskRepository taskRepo, IActivityRepository activityRepo, 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 aggregated from ALL agent sessions. /// Events are sorted by timestamp descending (newest first). /// Supports optional agent filter via ?agent= query parameter. /// Falls back to Iris-only feed if multi-agent feed fails. /// [HttpGet("operations")] public async Task> GetOperations( [FromQuery] int limit = 20, [FromQuery] string? agent = null) { try { var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100)); // Optional agent filter if (!string.IsNullOrWhiteSpace(agent)) { entries = entries .Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase) || string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase)) .ToList(); } return entries; } 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 agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim(); return await gateway.SendChatMessageAsync(agentId, 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) ? "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 new List(); } } /// /// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority). /// [HttpGet("queue")] public async Task> GetQueue(CancellationToken ct) { try { // Fetch cron jobs and open tasks concurrently var cronTask = gateway.GetQueueAsync(); var tasksTask = taskRepo.GetAllAsync(ct); await Task.WhenAll(cronTask, tasksTask); var cronJobs = cronTask.Result; var openTasks = tasksTask.Result .Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) .ToList(); var merged = new List(); // Map cron jobs (already in QueueItem format from gateway) merged.AddRange(cronJobs); // Map open tasks to QueueItems foreach (var t in openTasks) { var priority = NormalizePriority(t.Priority); merged.Add(new QueueItem( "task-" + t.Id.ToString(), t.Title, t.State, priority, "task", "--" )); } // Sort: high priority first, then medium, then low var priorityOrder = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["high"] = 0, ["medium"] = 1, ["low"] = 2 }; return merged.OrderBy(q => priorityOrder.GetValueOrDefault(q.Priority, 99)).ToList(); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard queue fetch failed"); return new List(); } } private static string NormalizePriority(string priority) { return priority.ToLowerInvariant() switch { "high" or "critical" or "urgent" => "high", "low" or "minor" => "low", _ => "medium" }; } /// /// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done. /// [HttpDelete("queue/{id}")] public async Task DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct) { try { if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase)) { var ok = await gateway.DeleteCronJobAsync(id); if (!ok) return StatusCode(502, new { error = "Gateway could not delete cron job" }); return NoContent(); } else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase)) { // Extract the actual GUID from the prefixed id ("task-{guid}") if (!id.StartsWith("task-")) return BadRequest(new { error = "Invalid task id format" }); var guidStr = id["task-".Length..]; if (!Guid.TryParse(guidStr, out var guid)) return BadRequest(new { error = "Invalid task id" }); var task = await taskRepo.GetByIdAsync(guid, ct); if (task is null) return NotFound(new { error = "Task not found" }); // Set task status to Done instead of deleting task.State = "Done"; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct); return NoContent(); } // Default: try cron var deleted = await gateway.DeleteCronJobAsync(id); if (!deleted) return NotFound(new { error = "Queue item not found" }); return NoContent(); } catch (Exception ex) { logger.LogWarning(ex, "Delete queue item failed for {Id}", id); return StatusCode(500, new { error = "Internal error" }); } } /// /// Changes the priority of a queue item (only for tasks; cron jobs are ignored). /// Cycles: high → medium → low → high. /// [HttpPut("queue/{id}/priority")] public async Task ChangeQueuePriority(string id, CancellationToken ct) { try { if (!id.StartsWith("task-")) return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }); var guidStr = id["task-".Length..]; if (!Guid.TryParse(guidStr, out var guid)) return BadRequest(new { error = "Invalid task id" }); var task = await taskRepo.GetByIdAsync(guid, ct); if (task is null) return NotFound(new { error = "Task not found" }); // Cycle priority: high → medium → low → high task.Priority = task.Priority.ToLowerInvariant() switch { "high" => "Medium", "medium" => "Low", "low" => "High", _ => "Medium" }; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct); return Ok(new { status = "ok", priority = task.Priority }); } catch (Exception ex) { logger.LogWarning(ex, "Change queue priority failed for {Id}", id); return StatusCode(500, new { error = "Internal error" }); } } /// /// Returns the current model and provider for a specific agent session. /// Calls session_status with the agent's session key. /// [HttpGet("agents/{id}/model")] public async Task> GetAgentModel(string id) { try { var info = await gateway.GetAgentModelAsync(id); if (info is null) return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" }); return Ok(info); } catch (Exception ex) { logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id); return StatusCode(500, new { error = "Internal error" }); } } /// /// Sets the model for a specific agent session. /// Calls session_status with model parameter. /// [HttpPut("agents/{id}/model")] public async Task SetAgentModel(string id, [FromBody] SetModelRequest request) { if (string.IsNullOrWhiteSpace(request.Model)) return BadRequest(new { error = "Model is required" }); try { var ok = await gateway.SetAgentModelAsync(id, request.Model); if (!ok) return StatusCode(502, new { error = "Gateway did not accept the change" }); return Ok(new { status = "ok", model = request.Model }); } catch (Exception ex) { logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id); return StatusCode(500, new { error = "Internal error" }); } } /// /// Returns the list of available models that can be assigned to agents. /// Reads from OpenClaw config dynamically, falls back to hardcoded list. /// [HttpGet("models")] public ActionResult> GetAvailableModels() { var models = gateway.GetAvailableModels(); return Ok(models); } // ========== Task Endpoints ========== /// /// Returns all non-done tasks (status != 'Done'), ordered by creation date descending. /// [HttpGet("tasks")] public async Task> GetTasks(CancellationToken ct) { try { var tasks = await taskRepo.GetAllAsync(ct); return tasks .Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase)) .OrderByDescending(t => t.CreatedAt) .Select(MapToDto) .ToList(); } catch (Exception ex) { logger.LogWarning(ex, "Dashboard tasks fetch failed"); return new List(); } } /// /// Creates a new task and logs an activity event. /// [HttpPost("tasks")] public async Task> CreateTask( [FromBody] CreateDashboardTaskRequest request, CancellationToken ct) { if (string.IsNullOrWhiteSpace(request.Title)) return BadRequest(new { error = "Title is required." }); var task = new WorkTask { Title = request.Title.Trim(), Detail = request.Detail?.Trim(), Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(), Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(), AssignedTo = request.AssignedTo?.Trim(), }; await taskRepo.AddAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct); return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task)); } /// /// Updates an existing task (title, detail, source, priority, assignedTo). /// [HttpPut("tasks/{id:guid}")] public async Task> UpdateTask( Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return NotFound(new { error = "Task not found." }); if (!string.IsNullOrWhiteSpace(request.Title)) task.Title = request.Title.Trim(); if (request.Detail is not null) task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim(); if (!string.IsNullOrWhiteSpace(request.Source)) task.Source = request.Source.Trim(); if (!string.IsNullOrWhiteSpace(request.Priority)) task.Priority = request.Priority.Trim(); if (request.AssignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim(); await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct); return Ok(MapToDto(task)); } /// /// Deletes a task (only if status is 'Done' or 'Backlog'). /// [HttpDelete("tasks/{id:guid}")] public async Task DeleteTask(Guid id, CancellationToken ct) { var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return NotFound(new { error = "Task not found." }); if (!TaskStateHelper.IsDoneOrBacklog(task.State)) return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" deleted" }, ct); await taskRepo.DeleteAsync(task, ct); return NoContent(); } /// /// Changes the status of a task. /// [HttpPatch("tasks/{id:guid}/status")] public async Task> UpdateTaskStatus( Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct) { if (!TaskStateHelper.IsValidState(request.Status)) return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }); var task = await taskRepo.GetByIdAsync(id, ct); if (task is null) return NotFound(new { error = "Task not found." }); var canonicalState = TaskStateHelper.AllStates.First(s => s.Equals(request.Status, StringComparison.OrdinalIgnoreCase)); task.State = canonicalState; await taskRepo.UpdateAsync(task, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonicalState}" }, ct); return Ok(MapToDto(task)); } // ========== Helpers ========== private static DashboardTaskDto MapToDto(WorkTask t) => new( t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt ); }