feat: complete Nexus mission-control workflows
This commit is contained in:
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
|
||||
{
|
||||
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(15);
|
||||
|
||||
private static readonly string[] SensitiveMarkers =
|
||||
[
|
||||
"api_key", "apikey", "api-key", "authorization", "bearer ", "password",
|
||||
"token", "secret", "x-nexus-api-key", "jwt", "private_key"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
// 3. Extract activity from session_status
|
||||
var isActive = false;
|
||||
string? currentTask = null;
|
||||
var statusText = status?["status"]?.GetValue<string>();
|
||||
if (status is not null)
|
||||
{
|
||||
// Check explicit isActive field
|
||||
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Fall back to status text
|
||||
var statusText = status["status"]?.GetValue<string>();
|
||||
if (!isActive && statusText is not null)
|
||||
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -191,6 +199,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
// 8. Calculate workload from queue items
|
||||
var workload = CalculateAgentWorkload(id, queueItems);
|
||||
|
||||
var statusKind = DeriveStatusKind(status, isActive);
|
||||
var statusDetail = DeriveStatusDetail(status, statusKind);
|
||||
|
||||
agents.Add(new DashboardAgentInfo(
|
||||
Id: id,
|
||||
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
||||
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
Workload: workload,
|
||||
Goal: goal,
|
||||
RoleBadge: DeriveRoleBadge(id),
|
||||
StatusLabel: DeriveStatusLabel(isActive, status),
|
||||
StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
|
||||
StatusKind: statusKind,
|
||||
StatusDetail: statusDetail,
|
||||
Elapsed: FormatElapsed(status),
|
||||
Think: null,
|
||||
Next: DeriveNext(isActive, currentTask)
|
||||
@@ -692,6 +705,72 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default)
|
||||
{
|
||||
var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "unknown";
|
||||
var requiredVersion = NormalizeOptional(configuration["Integrations:OpenClaw:RequiredVersion"]);
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
ApplyAuth(request);
|
||||
using var response = await httpClient.SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
string? version = response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues)
|
||||
? headerValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
version = TryGetString(root, "version")
|
||||
?? TryGetString(root, "gatewayVersion")
|
||||
?? TryGetString(root, "openclawVersion");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Health endpoint may be plain text.
|
||||
}
|
||||
}
|
||||
|
||||
version = NormalizeOptional(version);
|
||||
var pinned = requiredVersion is not null;
|
||||
var versionStatus = DetermineVersionStatus(response.IsSuccessStatusCode, version, requiredVersion);
|
||||
var matches = versionStatus is "matched" or "unpinned";
|
||||
var message = BuildGatewayMessage(response.IsSuccessStatusCode, versionStatus, requiredVersion);
|
||||
var warning = BuildGatewayWarning(response.IsSuccessStatusCode, versionStatus, version, requiredVersion, null);
|
||||
|
||||
return new GatewayRuntimeInfo(
|
||||
response.IsSuccessStatusCode,
|
||||
baseUrl,
|
||||
version,
|
||||
requiredVersion,
|
||||
pinned,
|
||||
response.IsSuccessStatusCode && matches,
|
||||
versionStatus,
|
||||
DateTimeOffset.UtcNow,
|
||||
message,
|
||||
warning);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var warning = BuildGatewayWarning(false, "error", null, requiredVersion, "Gateway nicht erreichbar");
|
||||
return new GatewayRuntimeInfo(
|
||||
false,
|
||||
baseUrl,
|
||||
null,
|
||||
requiredVersion,
|
||||
requiredVersion is not null,
|
||||
false,
|
||||
"error",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Gateway nicht erreichbar",
|
||||
warning);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCronJobAsync(string id)
|
||||
{
|
||||
try
|
||||
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
continue;
|
||||
|
||||
// Truncate content to first 200 chars for compact display
|
||||
var text = msg.Content.Length > 200
|
||||
? msg.Content[..200] + "…"
|
||||
: msg.Content;
|
||||
var redacted = AgentActivityText.RedactForDisplay(msg.Content);
|
||||
var text = redacted.Length > 200
|
||||
? redacted[..200] + "…"
|
||||
: redacted;
|
||||
var ts = ParseTimestamp(msg.Timestamp);
|
||||
var timeAgo = FormatTimeAgo(ts);
|
||||
|
||||
entries.Add(new AgentActivityEntry(timeAgo, text));
|
||||
entries.Add(new AgentActivityEntry(timeAgo, text, ts));
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
_ => "badge-slate"
|
||||
};
|
||||
|
||||
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
|
||||
private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
|
||||
{
|
||||
if (!isActive) return "Bereit";
|
||||
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
|
||||
return statusText switch
|
||||
return statusKind switch
|
||||
{
|
||||
"thinking" or "think" => "Plant",
|
||||
"blocked" or "block" => "Blockiert",
|
||||
_ => "Arbeitet"
|
||||
"connected" => isActive ? "Arbeitet" : "Verbunden",
|
||||
"thinking" => "Plant",
|
||||
"blocked" => "Blockiert",
|
||||
"stale" => "Stale",
|
||||
"error" => "Fehler",
|
||||
"unsupported" => "Unsupported",
|
||||
"ready" => "Bereit",
|
||||
_ => statusText?.ToLowerInvariant() switch
|
||||
{
|
||||
"thinking" or "think" => "Plant",
|
||||
"blocked" or "block" => "Blockiert",
|
||||
_ => isActive ? "Arbeitet" : "Bereit"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string DeriveStatusKind(JsonNode? status, bool isActive)
|
||||
{
|
||||
if (status is null)
|
||||
return "error";
|
||||
|
||||
var statusText = status["status"]?.GetValue<string>()?.Trim();
|
||||
var errorText = status["error"]?.GetValue<string>()?.Trim()
|
||||
?? status["message"]?.GetValue<string>()?.Trim();
|
||||
var normalized = statusText?.ToLowerInvariant();
|
||||
var detail = $"{statusText} {errorText}".Trim().ToLowerInvariant();
|
||||
|
||||
if (detail.Contains("unsupported", StringComparison.Ordinal))
|
||||
return "unsupported";
|
||||
if (!string.IsNullOrWhiteSpace(errorText)
|
||||
|| normalized is "error" or "failed" or "offline" or "disconnected" or "unreachable")
|
||||
return "error";
|
||||
if (normalized is "blocked" or "block")
|
||||
return "blocked";
|
||||
if (normalized is "thinking" or "think")
|
||||
return "thinking";
|
||||
|
||||
var lastActivity = TryGetStatusTimestamp(status);
|
||||
if (lastActivity is not null && DateTimeOffset.UtcNow - lastActivity.Value > StaleThreshold)
|
||||
return "stale";
|
||||
|
||||
if (isActive || normalized is "active" or "running" or "connected" or "online")
|
||||
return "connected";
|
||||
|
||||
return "ready";
|
||||
}
|
||||
|
||||
private static string? DeriveStatusDetail(JsonNode? status, string statusKind)
|
||||
{
|
||||
if (status is null)
|
||||
return "Gateway-Status nicht abrufbar";
|
||||
|
||||
var message = NormalizeOptional(status["message"]?.GetValue<string>())
|
||||
?? NormalizeOptional(status["error"]?.GetValue<string>())
|
||||
?? NormalizeOptional(status["detail"]?.GetValue<string>());
|
||||
|
||||
if (message is not null)
|
||||
return message;
|
||||
|
||||
return statusKind switch
|
||||
{
|
||||
"stale" => FormatStaleDetail(TryGetStatusTimestamp(status)),
|
||||
"unsupported" => "Session meldet einen nicht unterstützten Zustand",
|
||||
"error" => "Session-Status konnte nicht gelesen werden",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatElapsed(JsonNode? status)
|
||||
{
|
||||
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
||||
?? status?["lastMessage"]?.GetValue<string>();
|
||||
var lastActivity = TryGetStatusTimestamp(status);
|
||||
if (lastActivity is null) return null;
|
||||
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
|
||||
var diff = DateTimeOffset.UtcNow - ts;
|
||||
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
|
||||
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
|
||||
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
"main" => "Assistant",
|
||||
_ => "Custom"
|
||||
};
|
||||
|
||||
private static string? TryGetString(JsonElement root, string property)
|
||||
=> root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(property, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
public static string RedactSensitiveText(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return content;
|
||||
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var lower = lines[i].ToLowerInvariant();
|
||||
if (SensitiveMarkers.Any(marker => lower.Contains(marker, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
lines[i] = "[redacted sensitive line]";
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static DateTimeOffset? TryGetStatusTimestamp(JsonNode? status)
|
||||
{
|
||||
var raw = status?["lastActivity"]?.GetValue<string>()
|
||||
?? status?["lastMessage"]?.GetValue<string>()
|
||||
?? status?["updatedAt"]?.GetValue<string>();
|
||||
return DateTimeOffset.TryParse(raw, out var ts) ? ts : null;
|
||||
}
|
||||
|
||||
private static string DetermineVersionStatus(bool reachable, string? version, string? requiredVersion)
|
||||
{
|
||||
if (!reachable)
|
||||
return "error";
|
||||
if (requiredVersion is null)
|
||||
return version is null ? "unknown" : "unpinned";
|
||||
if (version is null)
|
||||
return "missing";
|
||||
return string.Equals(version, requiredVersion, StringComparison.OrdinalIgnoreCase) ? "matched" : "drift";
|
||||
}
|
||||
|
||||
private static string BuildGatewayMessage(bool reachable, string versionStatus, string? requiredVersion)
|
||||
{
|
||||
if (!reachable)
|
||||
return "Gateway nicht erreichbar";
|
||||
|
||||
return versionStatus switch
|
||||
{
|
||||
"matched" => "Gateway erreichbar und Version gepinnt",
|
||||
"missing" => requiredVersion is null
|
||||
? "Gateway erreichbar"
|
||||
: $"Gateway erreichbar, aber Versionspin {requiredVersion} nicht nachweisbar",
|
||||
"drift" => "Gateway erreichbar, aber Version weicht vom Pin ab",
|
||||
"unpinned" => "Gateway erreichbar",
|
||||
"unknown" => "Gateway erreichbar, Version nicht erkannt",
|
||||
_ => "Gateway erreichbar"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? BuildGatewayWarning(bool reachable, string versionStatus, string? version, string? requiredVersion, string? fallback)
|
||||
{
|
||||
if (!reachable)
|
||||
return fallback ?? "Gateway nicht erreichbar";
|
||||
|
||||
return versionStatus switch
|
||||
{
|
||||
"missing" when requiredVersion is not null => $"Gateway meldet keine Version; erwartet wird {requiredVersion}.",
|
||||
"drift" when requiredVersion is not null => $"Gateway meldet {version ?? "unknown"} statt {requiredVersion}.",
|
||||
"unknown" => "Gateway-Version konnte nicht erkannt werden.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatStaleDetail(DateTimeOffset? lastActivity)
|
||||
{
|
||||
if (lastActivity is null)
|
||||
return "Letzte Aktivität ist veraltet";
|
||||
|
||||
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
|
||||
if (diff.TotalMinutes < 60)
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalMinutes}m";
|
||||
if (diff.TotalHours < 24)
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalHours}h";
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalDays}d";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user