Files
nexus/backend/Controllers/DashboardController.cs
T
developer b1888bd8ef
CI - Build & Test / Backend (.NET) (push) Failing after 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
feat(dashboard): AgentModal live working feed & thinking stream
2026-06-11 16:13:28 +02:00

526 lines
18 KiB
C#

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<DashboardController> logger)
: ControllerBase
{
/// <summary>
/// Gateway health + session_status + subagents count.
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
/// </summary>
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
/// <summary>
/// Returns all agents with their current status.
/// Combines sessions_list + sub_agents_list.
/// </summary>
[HttpGet("agents")]
public async Task<List<DashboardAgentInfo>> GetAgents()
{
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return new List<DashboardAgentInfo>();
}
}
/// <summary>
/// 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.
/// </summary>
[HttpGet("operations")]
public async Task<List<FeedEntry>> 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<FeedEntry>();
}
}
/// <summary>
/// Send a chat message to the Iris session.
/// </summary>
[HttpPost("chat/send")]
public async Task<ChatResponse> 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");
}
}
/// <summary>
/// Returns chat messages (user + assistant only, not tool messages).
/// </summary>
[HttpGet("chat/messages")]
public async Task<List<MessageEntry>> 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<MessageEntry>();
}
}
/// <summary>
/// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
/// </summary>
[HttpGet("queue")]
public async Task<List<QueueItem>> 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<QueueItem>();
// 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<string, int>(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<QueueItem>();
}
}
private static string NormalizePriority(string priority)
{
return priority.ToLowerInvariant() switch
{
"high" or "critical" or "urgent" => "high",
"low" or "minor" => "low",
_ => "medium"
};
}
/// <summary>
/// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done.
/// </summary>
[HttpDelete("queue/{id}")]
public async Task<ActionResult> 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" });
}
}
/// <summary>
/// Changes the priority of a queue item (only for tasks; cron jobs are ignored).
/// Cycles: high → medium → low → high.
/// </summary>
[HttpPut("queue/{id}/priority")]
public async Task<ActionResult> 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" });
}
}
/// <summary>
/// Returns the current model and provider for a specific agent session.
/// Calls session_status with the agent's session key.
/// </summary>
[HttpGet("agents/{id}/model")]
public async Task<ActionResult<AgentModelInfo>> 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" });
}
}
/// <summary>
/// Sets the model for a specific agent session.
/// Calls session_status with model parameter.
/// </summary>
[HttpPut("agents/{id}/model")]
public async Task<ActionResult> 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" });
}
}
/// <summary>
/// Returns the most recent activity entries (assistant messages) for a specific agent.
/// </summary>
[HttpGet("agents/{id}/activity")]
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
{
try
{
return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id);
return new List<AgentActivityEntry>();
}
}
/// <summary>
/// Returns the list of available models that can be assigned to agents.
/// Reads from OpenClaw config dynamically, falls back to hardcoded list.
/// </summary>
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
{
var models = gateway.GetAvailableModels();
return Ok(models);
}
// ========== Task Endpoints ==========
/// <summary>
/// Returns all non-done tasks (status != 'Done'), ordered by creation date descending.
/// </summary>
[HttpGet("tasks")]
public async Task<List<DashboardTaskDto>> 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<DashboardTaskDto>();
}
}
/// <summary>
/// Creates a new task and logs an activity event.
/// </summary>
[HttpPost("tasks")]
public async Task<ActionResult<DashboardTaskDto>> 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));
}
/// <summary>
/// Updates an existing task (title, detail, source, priority, assignedTo).
/// </summary>
[HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> 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));
}
/// <summary>
/// Deletes a task (only if status is 'Done' or 'Backlog').
/// </summary>
[HttpDelete("tasks/{id:guid}")]
public async Task<ActionResult> 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();
}
/// <summary>
/// Changes the status of a task.
/// </summary>
[HttpPatch("tasks/{id:guid}/status")]
public async Task<ActionResult<DashboardTaskDto>> 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
);
}