348 lines
14 KiB
C#
348 lines
14 KiB
C#
using Nexus.Api.Models;
|
|
|
|
namespace Nexus.Api.Services;
|
|
|
|
public sealed class DashboardService(
|
|
IOpenClawGatewayClient gateway,
|
|
IOpenClawControlService openClaw,
|
|
IOpenClawChatService chat,
|
|
ITaskService taskService,
|
|
ILogger<DashboardService> logger) : IDashboardService
|
|
{
|
|
public async Task<DashboardStatus> 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<List<DashboardAgentInfo>> 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<List<FeedEntry>> 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<ChatResponse> 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<List<MessageEntry>> 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<List<QueueItem>> 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<GatewayRuntimeInfo> 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<QueueDeleteResult> 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<QueuePriorityResult> 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<AgentModelInfo?> 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<bool> SetAgentModelAsync(string agentId, string model)
|
|
{
|
|
var result = await openClaw.PatchSessionModelAsync(
|
|
$"agent:{agentId}:main",
|
|
model);
|
|
return result.Ok;
|
|
}
|
|
|
|
public async Task<List<AgentActivityEntry>> 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<List<ModelOption>> 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<string, int> 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";
|
|
}
|
|
}
|