feat: ship agent-first mission control v0.2.57
This commit is contained in:
@@ -4,68 +4,118 @@ 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()
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetStatusAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard status check failed");
|
||||
return new DashboardStatus(false, "Offline", 0, 0);
|
||||
}
|
||||
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()
|
||||
{
|
||||
try
|
||||
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 =>
|
||||
{
|
||||
return await gateway.GetAgentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||
return [];
|
||||
}
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||
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();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agentFilter))
|
||||
{
|
||||
entries = entries
|
||||
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||
return [];
|
||||
}
|
||||
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
|
||||
{
|
||||
return await gateway.SendChatMessageAsync(agentId, message);
|
||||
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, "Gateway nicht erreichbar");
|
||||
return new ChatResponse(false, null, "OpenClaw chat endpoint is not enabled or reachable.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,11 +141,17 @@ public sealed class DashboardService(
|
||||
{
|
||||
try
|
||||
{
|
||||
var cronTask = gateway.GetQueueAsync();
|
||||
var cronTask = openClaw.GetCronJobsAsync(200, ct);
|
||||
var tasksTask = taskService.GetOpenAsync(ct);
|
||||
await Task.WhenAll(cronTask, tasksTask);
|
||||
|
||||
var merged = new List<QueueItem>(cronTask.Result);
|
||||
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", "--"));
|
||||
@@ -114,24 +170,32 @@ public sealed class DashboardService(
|
||||
|
||||
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetGatewayInfoAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Gateway info fetch failed");
|
||||
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
|
||||
}
|
||||
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))
|
||||
{
|
||||
var ok = await gateway.DeleteCronJobAsync(id);
|
||||
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
|
||||
}
|
||||
return new QueueDeleteResult(QueueDeleteOutcome.Ignored);
|
||||
|
||||
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
|
||||
{
|
||||
@@ -147,8 +211,7 @@ public sealed class DashboardService(
|
||||
};
|
||||
}
|
||||
|
||||
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
|
||||
return new QueueDeleteResult(QueueDeleteOutcome.NotFound);
|
||||
}
|
||||
|
||||
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
|
||||
@@ -169,44 +232,52 @@ public sealed class DashboardService(
|
||||
|
||||
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentModelAsync(agentId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
|
||||
return null;
|
||||
}
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.SetAgentModelAsync(agentId, model);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
|
||||
return false;
|
||||
}
|
||||
var result = await openClaw.PatchSessionModelAsync(
|
||||
$"agent:{agentId}:main",
|
||||
model);
|
||||
return result.Ok;
|
||||
}
|
||||
|
||||
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
|
||||
return [];
|
||||
}
|
||||
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 List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
|
||||
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
|
||||
{
|
||||
@@ -219,4 +290,58 @@ public sealed class DashboardService(
|
||||
{
|
||||
["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";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user