feat(stability): unify readiness and recovery
CI - Build & Test / Backend (.NET) (push) Successful in 45s
CI - Build & Test / Backend integration (PostgreSQL/Toxiproxy) (push) Failing after 1m0s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m49s
CI - Build & Test / Security Check (push) Successful in 7s
CI - Build & Test / Deploy Nexus (push) Has been skipped

This commit is contained in:
AzuTear
2026-08-01 01:21:33 +02:00
parent 38282e4f7f
commit cd8c78d165
67 changed files with 2616 additions and 601 deletions
+26 -7
View File
@@ -125,6 +125,21 @@ docker run --rm \
docker compose --env-file /tmp/nexus-deploy-env ps
' < "$ENV_TMPFILE"
echo "Checking container readiness"
retry=0
while [ "$retry" -lt 6 ]; do
retry=$((retry + 1))
if docker exec nexus-api-1 curl -fs --max-time 5 http://localhost:8080/health/ready >/dev/null; then
echo "API container is ready"
break
fi
if [ "$retry" -eq 6 ]; then
echo "API container readiness failed" >&2
exit 1
fi
sleep "$retry"
done
echo "Verifying image provenance"
for container in nexus-api-1 nexus-web-1; do
revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")"
@@ -140,9 +155,13 @@ for container in nexus-api-1 nexus-web-1; do
echo "$container provenance verified: v$version $revision"
done
echo "Checking live health"
health_is_healthy() {
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health")" || return 1
echo "Checking public readiness and dependency health"
readiness_is_healthy() {
curl -fs --max-time 10 "$BASE_URL/health/ready" >/dev/null
}
runtime_is_healthy() {
health_body="$(curl -fs --max-time 10 "$BASE_URL/health")" || return 1
printf '%s' "$health_body" |
grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'
}
@@ -150,8 +169,8 @@ health_is_healthy() {
retry=0
while [ "$retry" -lt 6 ]; do
retry=$((retry + 1))
if health_is_healthy; then
echo "Health check passed with Healthy runtime and database state"
if readiness_is_healthy && runtime_is_healthy; then
echo "Readiness and full dependency health passed"
break
fi
if [ "$retry" -eq 6 ]; then
@@ -177,8 +196,8 @@ check() {
}
check_health() {
if health_is_healthy; then
printf '%-28s HTTP 200 (Healthy)\n' "Health"
if readiness_is_healthy && runtime_is_healthy; then
printf '%-28s HTTP 200 (Ready + Healthy)\n' "Health"
pass=$((pass + 1))
else
printf '%-28s not healthy\n' "Health"
+50 -24
View File
@@ -35,6 +35,14 @@ jobs:
- name: Build
run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
- name: Block high or critical backend vulnerabilities
run: |
dotnet list backend/Nexus.Api.csproj package --vulnerable --include-transitive --format json > /tmp/nexus-dotnet-vulnerabilities.json
if grep -Eiq '"severity"[[:space:]]*:[[:space:]]*"(high|critical)"' /tmp/nexus-dotnet-vulnerabilities.json; then
cat /tmp/nexus-dotnet-vulnerabilities.json
exit 1
fi
- name: Verify OpenAPI contract
run: |
test -f backend/openapi/Nexus.Api.json
@@ -43,17 +51,36 @@ jobs:
- name: Test
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
- name: Docker integration tests
if: ${{ vars.NEXUS_RUN_DOCKER_INTEGRATION_TESTS == 'true' }}
timeout-minutes: 15
backend-integration:
name: Backend integration (PostgreSQL/Toxiproxy)
runs-on: linux
timeout-minutes: 20
env:
NEXUS_RUN_DOCKER_INTEGRATION_TESTS: "true"
NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS: ${{ vars.NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS }}
NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Verify Docker endpoint
run: docker info
- name: Restore and build
run: |
dotnet restore backend-tests/Nexus.Api.Tests.csproj
dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
- name: Run all container-backed contracts
run: >-
dotnet test backend-tests/Nexus.Api.Tests.csproj
--no-build
--configuration Release
--filter "Category=DockerIntegration"
--filter "Category=DockerIntegration|Category=ToxiproxyIntegration"
--verbosity normal
# ─── Frontend ──────────────────────────────────
@@ -78,6 +105,14 @@ jobs:
run: pnpm install --frozen-lockfile
working-directory: frontend
- name: Verify release version
run: test "$(node -p "require('./package.json').version")" = "$(tr -d '[:space:]' < ../VERSION)"
working-directory: frontend
- name: Block high or critical production vulnerabilities
run: pnpm audit --prod --audit-level high
working-directory: frontend
- name: Type check
run: pnpm typecheck
working-directory: frontend
@@ -112,31 +147,22 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for .env leaks
- name: Gitleaks v8.30.1
env:
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
run: |
echo "🔍 Scanning for potential secrets in source code..."
HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$HITS" ]; then
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
echo "$HITS"
echo ""
echo "Remove these values and use environment variables or a secrets manager instead."
exit 1
fi
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$LOOSE" ]; then
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
echo "$LOOSE"
else
echo "✅ No obvious secrets found"
fi
curl -fsSL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo "$GITLEAKS_SHA256 /tmp/gitleaks.tar.gz" | sha256sum -c -
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
/tmp/gitleaks git --redact --no-banner --exit-code 1 .
deploy:
name: Deploy Nexus
runs-on: linux
needs: [backend, frontend, security]
needs: [backend, backend-integration, frontend, security]
concurrency:
group: deploy-production
cancel-in-progress: false
+13 -4
View File
@@ -188,8 +188,16 @@ jobs:
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
READY_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 https://nexus.noveria.net/health/ready || true)
HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
if printf '%s' "$HEALTH_BODY" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then
# Releases before v0.2.60 have no readiness endpoint; retain a
# visible compatibility fallback for emergency rollback only.
if [ "$READY_CODE" = "200" ] || [ "$READY_CODE" = "404" ]; then
READY_OK=true
else
READY_OK=false
fi
if [ "$READY_OK" = "true" ] && printf '%s' "$HEALTH_BODY" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then
echo ""
echo "✅ Health check passed with Healthy runtime and database state (attempt $RETRY/$MAX)"
exit 0
@@ -227,11 +235,12 @@ jobs:
}
check_health() {
local body
local body ready_code
ready_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}/health/ready" || true)
body=$(curl -sf --max-time 10 "${BASE}/health" || true)
printf " %-25s" "Health API:"
if printf '%s' "$body" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then
echo " HTTP 200 Healthy ✅"
if { [ "$ready_code" = "200" ] || [ "$ready_code" = "404" ]; } && printf '%s' "$body" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then
echo " ready=${ready_code} full=Healthy ✅"
PASS=$((PASS + 1))
else
echo " not healthy ❌"
+3
View File
@@ -0,0 +1,3 @@
b7b44494f07ac95283894e1da7e20e6b63d8f36b:docs/gateway-api-research.md:generic-api-key:29
b7b44494f07ac95283894e1da7e20e6b63d8f36b:docs/gateway-api-research.md:generic-api-key:333
eeb6174de0b806b04ffc0dec5353d2ee67f8e36f:.env.example:generic-api-key:6
+6
View File
@@ -0,0 +1,6 @@
<Project>
<PropertyGroup>
<NexusVersion>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)VERSION').Trim())</NexusVersion>
<Version>$(NexusVersion)</Version>
</PropertyGroup>
</Project>
+31 -3
View File
@@ -44,6 +44,13 @@ isolated from the frontend and the Nexus domain model.
> Gitea run 362 deployed commit `144edf58fe5928a3e04816f4435ea574d64211a1`;
> PostgreSQL and the OpenClaw HTTP runtime are healthy. Productive Protocol-v4
> management remains intentionally blocked by the external Client-ID gate.
>
> **Stability and recovery v0.2.60 candidate (2026-07-31):**
> [implementation and acceptance](docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md).
> Readiness, browser-origin protection, generated ProblemDetails metadata,
> shared UI recovery states, version provenance and mandatory container-backed
> CI are implemented. The audit separates locally proven behavior from the
> Linux/Docker, deployment, credentialed-production and OpenClaw-write gates.
> 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere
> Backend-Brücke und Gateway-Integration geprüft. Siehe
@@ -73,6 +80,10 @@ isolated from the frontend and the Nexus domain model.
- keyset-paginated Task Board with targeted live card reconciliation
- structured mutation results with entity references, trace metadata and
cross-page frontend deep links
- one generated `ProblemDetails` contract and shared loading, empty, error,
offline, stale and partial presentation across all authenticated views
- separate process liveness, database-backed readiness and full runtime
diagnostics used consistently by deploy and rollback
- Responsive dark-mode operations dashboard
- Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
@@ -96,6 +107,8 @@ cp .env.template .env
# BOOTSTRAP_OWNER_EMAIL, BOOTSTRAP_OWNER_PASSWORD and the OpenClaw credential.
# Pin OPENCLAW_REQUIRED_VERSION for a production deployment.
docker compose up --build -d
curl http://127.0.0.1:18880/health/live
curl http://127.0.0.1:18880/health/ready
curl http://127.0.0.1:18880/health
```
@@ -193,11 +206,17 @@ The dashboard prioritizes the live agent topology:
- Access tokens expire after 15 minutes and are held only in browser memory.
- Refresh tokens are random, stored only as SHA-256 hashes in PostgreSQL, rotated on use and checked for reuse.
- The browser receives the refresh token only as a `HttpOnly`, `Secure`, `SameSite=Strict` cookie.
- Cookie-backed refresh and logout reject explicit cross-site browser requests
through `Origin` and `Sec-Fetch-Site` validation. Non-browser API clients
without provenance headers still require a valid refresh cookie and the
normal rate limit.
- Login and refresh endpoints are rate-limited per forwarded client IP (5 attempts/minute).
- The API uses an authenticated-by-default fallback policy; only the login and
recovery flow plus the explicit liveness/Gateway-health probes are anonymous.
- Swagger is enabled only in the Development environment.
- CSRF protection via `X-CSRF-TOKEN` header and `nexus-csrf` cookie (not HttpOnly).
- The unused antiforgery-token endpoint was removed. Nexus does not advertise a
token that no mutation validates; strict cookies plus the refresh/logout
origin guard form the browser boundary for those anonymous cookie calls.
### Security
@@ -464,8 +483,9 @@ Response-Format (TaskBridgeCommandResponse<T>):
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/health` | No | Health check with runtime + PostgreSQL |
| `GET` | `/api/v1/auth/csrf` | No | Get CSRF token |
| `GET` | `/health/live` | No | Process liveness only; no dependency probe |
| `GET` | `/health/ready` | No | `200` only when Nexus can serve through PostgreSQL; otherwise `503` |
| `GET` | `/health` | No | Full diagnostic projection for PostgreSQL and OpenClaw runtime; may return `Degraded` with HTTP 200 so the UI remains available for recovery |
| `POST` | `/api/v1/auth/login` | No (rate-limited) | Login with email/password |
| `POST` | `/api/v1/auth/refresh` | No (rate-limited) | Refresh access token |
| `POST` | `/api/v1/auth/logout` | No | Clear refresh token |
@@ -473,6 +493,14 @@ Response-Format (TaskBridgeCommandResponse<T>):
| `PATCH` | `/api/v1/auth/profile` | Yes | Update display name |
| `POST` | `/api/v1/auth/change-password` | Yes | Change password (min 10 chars) |
Ordinary API failures use `application/problem+json` with a stable `code`, HTTP
`status`, `title`, `detail` and `traceId`. Conflict revisions, operation IDs and
retry delays are included only when applicable. The generated OpenAPI contract
is the frontend source for these fields; mutations are never automatically
retried. Durable operation endpoints retain their typed operation envelope for
states such as `partial`, `failed` and `in_doubt`; those states must remain
inspectable and are not collapsed into a transient HTTP exception.
### Operations
| Method | Path | Description |
+1 -1
View File
@@ -1 +1 @@
0.2.59
0.2.60
+42
View File
@@ -1,6 +1,7 @@
using System.Reflection;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Primitives;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
@@ -209,6 +210,47 @@ public sealed class AuthServiceTests
Assert.Equal(originalHash, updated.PasswordHash);
}
[Fact]
public async Task RefreshToken_SurvivesServiceRestart_WhenDatabasePersists()
{
var databaseRoot = new InMemoryDatabaseRoot();
var databaseName = Guid.NewGuid().ToString();
var configuration = new MemoryConfig(new Dictionary<string, string?>
{
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
["Jwt:Issuer"] = "nexus-test",
["Jwt:Audience"] = "nexus-test-web",
});
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
AuthSession initialSession;
await using (var firstDb = new NexusDbContext(
new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(databaseName, databaseRoot)
.Options))
{
await SeedUserAsync(firstDb, "restart@example.com", "RestartPassword123!");
var firstService = new AuthService(new UserRepository(firstDb), configuration, logger);
initialSession = Assert.IsType<AuthSession>(
await firstService.LoginAsync(Login("restart@example.com", "RestartPassword123!")));
}
await using var restartedDb = new NexusDbContext(
new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(databaseName, databaseRoot)
.Options);
var restartedService = new AuthService(
new UserRepository(restartedDb),
configuration,
logger);
var refreshed = await restartedService.RefreshAsync(initialSession.RefreshToken);
Assert.NotNull(refreshed);
Assert.NotEqual(initialSession.RefreshToken, refreshed.RefreshToken);
Assert.Equal(initialSession.User.Id, refreshed.User.Id);
}
// ══════════════════════════════════════════════════════════════════
// Change Password Tests
// ══════════════════════════════════════════════════════════════════
+69
View File
@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.Controllers;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class HealthControllerTests
{
[Fact]
public async Task Ready_Returns200_WhenRequiredChecksAreHealthy()
{
using var provider = BuildProvider(HealthCheckResult.Healthy());
var controller = new HealthController(
runtime: null!,
provider.GetRequiredService<HealthCheckService>());
var result = await controller.Ready(CancellationToken.None);
Assert.Equal(StatusCodes.Status200OK, Assert.IsAssignableFrom<IStatusCodeHttpResult>(result).StatusCode);
}
[Fact]
public async Task Ready_Returns503_WhenRequiredCheckIsUnhealthy()
{
using var provider = BuildProvider(HealthCheckResult.Unhealthy("database unavailable"));
var controller = new HealthController(
runtime: null!,
provider.GetRequiredService<HealthCheckService>());
var result = await controller.Ready(CancellationToken.None);
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
Assert.IsAssignableFrom<IStatusCodeHttpResult>(result).StatusCode);
}
[Fact]
public async Task Ready_Returns503_WhenPostgreSqlCannotBeReached()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddHealthChecks()
.AddNpgSql(
"Host=127.0.0.1;Port=1;Database=nexus;Username=nexus;Password=unused;Timeout=1;Command Timeout=1",
name: "postgresql",
tags: ["database", "ready"]);
using var provider = services.BuildServiceProvider();
var controller = new HealthController(
runtime: null!,
provider.GetRequiredService<HealthCheckService>());
var result = await controller.Ready(CancellationToken.None);
Assert.Equal(
StatusCodes.Status503ServiceUnavailable,
Assert.IsAssignableFrom<IStatusCodeHttpResult>(result).StatusCode);
}
private static ServiceProvider BuildProvider(HealthCheckResult result)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddHealthChecks()
.AddCheck("required", () => result, tags: ["ready"]);
return services.BuildServiceProvider();
}
}
@@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Http;
using Xunit;
namespace Nexus.Api.Tests;
public sealed class ProblemDetailsContractTests
{
[Theory]
[InlineData(400, NexusProblemCodes.ValidationFailed)]
[InlineData(401, NexusProblemCodes.Unauthenticated)]
[InlineData(403, NexusProblemCodes.Forbidden)]
[InlineData(409, NexusProblemCodes.Conflict)]
[InlineData(429, NexusProblemCodes.RateLimited)]
[InlineData(503, NexusProblemCodes.DependencyUnavailable)]
[InlineData(504, NexusProblemCodes.Timeout)]
[InlineData(500, NexusProblemCodes.InternalError)]
public void Defaults_AddStableCodeAndTraceId(int status, string expectedCode)
{
var context = new DefaultHttpContext { TraceIdentifier = "trace-contract" };
var problem = new ProblemDetails { Status = status };
NexusProblemDetailsDefaults.Apply(problem, context);
Assert.Equal(expectedCode, problem.Extensions["code"]);
Assert.Equal("trace-contract", problem.Extensions["traceId"]);
Assert.False(string.IsNullOrWhiteSpace(problem.Title));
Assert.False(string.IsNullOrWhiteSpace(problem.Type));
}
[Fact]
public void Defaults_PreserveExplicitMachineCode()
{
var context = new DefaultHttpContext { TraceIdentifier = "trace-contract" };
var problem = new ProblemDetails { Status = 409 };
problem.Extensions["code"] = "config_hash_conflict";
NexusProblemDetailsDefaults.Apply(problem, context);
Assert.Equal("config_hash_conflict", problem.Extensions["code"]);
}
[Fact]
public void LegacyError_IsConvertedWithoutLosingTheStableContract()
{
var context = new DefaultHttpContext { TraceIdentifier = "trace-legacy" };
var problem = NexusProblemDetailsDefaults.FromLegacyError(
new { error = "Task not found." },
StatusCodes.Status404NotFound,
context);
Assert.NotNull(problem);
Assert.Equal("Task not found.", problem.Detail);
Assert.Equal(NexusProblemCodes.NotFound, problem.Extensions["code"]);
Assert.Equal("trace-legacy", problem.Extensions["traceId"]);
}
[Fact]
public void LegacySuccessPayload_IsNotConverted()
{
var context = new DefaultHttpContext();
var problem = NexusProblemDetailsDefaults.FromLegacyError(
new { error = "informational field" },
StatusCodes.Status200OK,
context);
Assert.Null(problem);
}
[Fact]
public void SharedResult_UsesProblemJsonAndPreservesTypedExtensions()
{
var result = NexusHttpResults.Problem(
StatusCodes.Status409Conflict,
"The agent file changed.",
extensions: new Dictionary<string, object?>
{
["currentHash"] = "hash-current"
});
Assert.Equal(
"application/problem+json",
Assert.IsAssignableFrom<IContentTypeHttpResult>(result).ContentType);
var problem = Assert.IsType<ProblemDetails>(
Assert.IsAssignableFrom<IValueHttpResult>(result).Value);
Assert.Equal(NexusProblemCodes.Conflict, problem.Extensions["code"]);
Assert.Equal("hash-current", problem.Extensions["currentHash"]);
}
}
+30 -1
View File
@@ -10,6 +10,7 @@ using Microsoft.Extensions.Options;
using Nexus.Api.Controllers;
using Nexus.Api.Extensions;
using Nexus.Api.Middleware;
using Nexus.Api.Security;
using Nexus.Api.Services;
using Xunit;
@@ -90,7 +91,6 @@ public sealed class SecurityBoundaryTests
}
[Theory]
[InlineData(nameof(AuthController.GetCsrfToken))]
[InlineData(nameof(AuthController.Login))]
[InlineData(nameof(AuthController.Refresh))]
[InlineData(nameof(AuthController.Logout))]
@@ -104,6 +104,7 @@ public sealed class SecurityBoundaryTests
[Theory]
[InlineData(nameof(HealthController.Live))]
[InlineData(nameof(HealthController.Ready))]
[InlineData(nameof(HealthController.Get))]
public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName)
{
@@ -123,6 +124,34 @@ public sealed class SecurityBoundaryTests
Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
}
[Fact]
public void CookieBackedAuth_AllowsSameOriginBrowserRequests()
{
var context = new DefaultHttpContext();
context.Request.Scheme = "https";
context.Request.Host = new HostString("nexus.noveria.net");
context.Request.Headers.Origin = "https://nexus.noveria.net";
context.Request.Headers["Sec-Fetch-Site"] = "same-origin";
Assert.True(BrowserRequestOriginGuard.IsAllowed(context.Request));
}
[Theory]
[InlineData("https://attacker.invalid", "cross-site")]
[InlineData("https://attacker.invalid", "")]
[InlineData("not-an-origin", "same-origin")]
public void CookieBackedAuth_RejectsExplicitCrossSiteRequests(string origin, string fetchSite)
{
var context = new DefaultHttpContext();
context.Request.Scheme = "https";
context.Request.Host = new HostString("nexus.noveria.net");
context.Request.Headers.Origin = origin;
if (fetchSite.Length > 0)
context.Request.Headers["Sec-Fetch-Site"] = fetchSite;
Assert.False(BrowserRequestOriginGuard.IsAllowed(context.Request));
}
[Fact]
public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication()
{
+10 -3
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Http;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
@@ -74,7 +75,9 @@ public class AdminController(
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
if (existing is not null)
return Results.Conflict(new { error = "A user with this email already exists." });
return NexusHttpResults.Problem(
StatusCodes.Status409Conflict,
"A user with this email already exists.");
var user = new NexusUser
{
@@ -108,7 +111,9 @@ public class AdminController(
{
var user = await userRepository.GetByIdAsync(id, ct);
if (user is null)
return Results.NotFound(new { error = "User not found." });
return NexusHttpResults.Problem(
StatusCodes.Status404NotFound,
"User not found.");
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
@@ -142,7 +147,9 @@ public class AdminController(
var user = await userRepository.GetByIdAsync(id, ct);
if (user is null)
return Results.NotFound(new { error = "User not found." });
return NexusHttpResults.Problem(
StatusCodes.Status404NotFound,
"User not found.");
// Niemals owner überschreiben
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
+21 -11
View File
@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.Security.Claims;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
@@ -160,9 +161,15 @@ public class AgentsController(
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{
if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." });
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["content"] = ["Content is required."]
});
if (string.IsNullOrWhiteSpace(request.ExpectedHash))
return Results.BadRequest(new { error = "ExpectedHash is required." });
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["expectedHash"] = ["ExpectedHash is required."]
});
try
{
@@ -170,7 +177,10 @@ public class AgentsController(
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
if (string.IsNullOrWhiteSpace(idempotencyKey))
{
return Results.BadRequest(new { error = "Idempotency-Key header is required." });
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Idempotency-Key"] = ["Idempotency-Key header is required."]
});
}
var invocation = OpenClawInvocationContext.Create(
@@ -204,15 +214,15 @@ public class AgentsController(
}
catch (OpenClawAgentConfigurationConflictException ex)
{
return Results.Json(
new
return NexusHttpResults.Problem(
StatusCodes.Status409Conflict,
ex.Message,
NexusProblemCodes.Conflict,
extensions: new Dictionary<string, object?>
{
code = ex.Code,
message = ex.Message,
ex.ExpectedHash,
ex.CurrentHash
},
statusCode: StatusCodes.Status409Conflict);
["expectedHash"] = ex.ExpectedHash,
["currentHash"] = ex.CurrentHash
});
}
catch (OpenClawAgentConfigurationValidationException ex)
{
+39 -19
View File
@@ -1,11 +1,12 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Nexus.Api.DTOs;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.RateLimiting;
using Nexus.Api.Security;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -14,19 +15,10 @@ namespace Nexus.Api.Controllers;
[Route("api/v1/auth")]
public class AuthController(
IAuthService authService,
IAntiforgery antiforgery,
IConfiguration config,
IHostEnvironment env,
LoginAttemptTracker attemptTracker) : ControllerBase
{
[HttpGet("csrf")]
[AllowAnonymous]
public IActionResult GetCsrfToken()
{
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
return Ok(new { token = tokens.RequestToken });
}
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
@@ -51,14 +43,16 @@ public class AuthController(
HttpContext.Response.Headers["X-RateLimit-Reset"] =
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
// Return a structured body so the frontend can display remaining attempts
return Results.Json(new
return Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Authentication failed",
detail: "Invalid email or password.",
extensions: new Dictionary<string, object?>
{
error = "invalid_credentials",
message = "Invalid email or password.",
remaining,
retryAfterSeconds
}, statusCode: 401);
["code"] = NexusProblemCodes.Unauthenticated,
["remaining"] = remaining,
["retryAfterSeconds"] = retryAfterSeconds
});
}
// Success — reset attempt counter
@@ -75,14 +69,17 @@ public class AuthController(
[EnableRateLimiting("auth")]
public async Task<IResult> Refresh(CancellationToken ct)
{
if (!BrowserRequestOriginGuard.IsAllowed(Request))
return CrossSiteRequestRejected();
if (!Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
return Results.Unauthorized();
return Unauthenticated("No active refresh session was provided.");
var session = await authService.RefreshAsync(refreshToken!, ct);
if (session is null)
{
ClearRefreshCookie(Response);
return Results.Unauthorized();
return Unauthenticated("The refresh session is invalid or expired.");
}
SetRefreshCookie(Response, session.RefreshToken);
@@ -96,6 +93,9 @@ public class AuthController(
[AllowAnonymous]
public async Task<IResult> Logout(CancellationToken ct)
{
if (!BrowserRequestOriginGuard.IsAllowed(Request))
return CrossSiteRequestRejected();
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
await authService.RevokeAsync(refreshToken!, ct);
@@ -168,6 +168,26 @@ public class AuthController(
User = session.User
};
private static IResult Unauthenticated(string detail)
=> Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Authentication required",
detail: detail,
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Unauthenticated
});
private static IResult CrossSiteRequestRejected()
=> Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Cross-site request rejected",
detail: "Refresh and logout requests must originate from the Nexus origin.",
extensions: new Dictionary<string, object?>
{
["code"] = NexusProblemCodes.Forbidden
});
private void SetRefreshCookie(HttpResponse response, string token)
{
var days = config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
+20
View File
@@ -18,6 +18,26 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
agentSource = "openclaw-rpc"
});
[AllowAnonymous]
[HttpGet("/health/ready")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
public async Task<IResult> Ready(CancellationToken ct)
{
var report = await healthChecks.CheckHealthAsync(
registration => registration.Tags.Contains("ready"),
ct);
var payload = new
{
status = report.Status == HealthStatus.Healthy ? "Healthy" : "Unhealthy",
timestamp = DateTimeOffset.UtcNow
};
return report.Status == HealthStatus.Healthy
? Results.Ok(payload)
: Results.Json(payload, statusCode: StatusCodes.Status503ServiceUnavailable);
}
[AllowAnonymous]
[HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct)
@@ -1,4 +1,4 @@
using Nexus.Api.Models;
using Nexus.Api.Http;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
@@ -28,21 +28,32 @@ internal static class OpenClawContentReadEndpoint
"disconnected" => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status409Conflict
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
exception.State,
return NexusHttpResults.Problem(
status,
exception.Message,
exception.Method,
exception.RequiredScope),
statusCode: status);
status switch
{
StatusCodes.Status403Forbidden => NexusProblemCodes.Forbidden,
StatusCodes.Status503ServiceUnavailable => NexusProblemCodes.DependencyUnavailable,
_ => NexusProblemCodes.Conflict
},
extensions: new Dictionary<string, object?>
{
["method"] = exception.Method,
["requiredScope"] = exception.RequiredScope,
["state"] = exception.State
});
}
catch (OpenClawAgentConfigurationVerificationException)
{
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
"verification_failed",
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."),
statusCode: StatusCodes.Status502BadGateway);
return NexusHttpResults.Problem(
StatusCodes.Status502BadGateway,
"OpenClaw-Antwort konnte nicht sicher verifiziert werden.",
NexusProblemCodes.DependencyUnavailable,
extensions: new Dictionary<string, object?>
{
["state"] = "verification_failed"
});
}
catch (OpenClawGatewayRpcException exception)
{
@@ -60,9 +71,16 @@ internal static class OpenClawContentReadEndpoint
StatusCodes.Status504GatewayTimeout,
_ => StatusCodes.Status502BadGateway
};
return Results.Json(
new OpenClawAgentConfigurationErrorDto(
code.ToLowerInvariant(),
var problemCode = status switch
{
StatusCodes.Status403Forbidden => NexusProblemCodes.Forbidden,
StatusCodes.Status409Conflict => NexusProblemCodes.UnsupportedCapability,
StatusCodes.Status503ServiceUnavailable => NexusProblemCodes.DependencyUnavailable,
StatusCodes.Status504GatewayTimeout => NexusProblemCodes.Timeout,
_ => NexusProblemCodes.DependencyUnavailable
};
return NexusHttpResults.Problem(
status,
status switch
{
StatusCodes.Status403Forbidden =>
@@ -74,8 +92,12 @@ internal static class OpenClawContentReadEndpoint
StatusCodes.Status504GatewayTimeout =>
"OpenClaw hat nicht rechtzeitig geantwortet.",
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
}),
statusCode: status);
},
problemCode,
extensions: new Dictionary<string, object?>
{
["state"] = code.ToLowerInvariant()
});
}
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
WORKDIR /src
COPY Nexus.Api.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish
RUN dotnet publish -c Release -o /app/publish \
/p:Version="${NEXUS_VERSION}" \
/p:InformationalVersion="${NEXUS_VERSION}+${NEXUS_GIT_SHA}"
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
ARG NEXUS_VERSION=dev
@@ -1,5 +1,6 @@
using System.Diagnostics;
using Nexus.Api.Observability;
using Nexus.Api.Http;
using Npgsql;
using OpenTelemetry;
using OpenTelemetry.Exporter;
@@ -20,14 +21,14 @@ public static class PlatformServiceCollectionExtensions
this IServiceCollection services,
IConfiguration configuration)
{
services.AddOpenApi("v1");
services.AddOpenApi("v1", options =>
options.AddSchemaTransformer<NexusProblemDetailsSchemaTransformer>());
services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
context.ProblemDetails.Extensions["traceId"] =
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
};
NexusProblemDetailsDefaults.Apply(
context.ProblemDetails,
context.HttpContext);
});
var telemetry = services.AddOpenTelemetry()
@@ -10,6 +10,7 @@ using Microsoft.IdentityModel.Tokens;
using ModelContextProtocol.AspNetCore;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Http;
using Nexus.Api.RateLimiting;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
@@ -28,7 +29,7 @@ namespace Nexus.Api.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures JWT authentication, authorization, and antiforgery.
/// Configures JWT authentication and authorization.
/// </summary>
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
{
@@ -63,14 +64,6 @@ public static class ServiceCollectionExtensions
.RequireAuthenticatedUser()
.Build();
});
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
return services;
}
@@ -109,7 +102,7 @@ public static class ServiceCollectionExtensions
Status = StatusCodes.Status429TooManyRequests,
Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)."
};
body.Extensions["code"] = "rate_limit_exceeded";
body.Extensions["code"] = NexusProblemCodes.RateLimited;
body.Extensions["remaining"] = 0;
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
body.Extensions["traceId"] =
@@ -272,11 +265,15 @@ public static class ServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddNexusApplicationServices(
this IServiceCollection services,
bool includeHostedServices = true)
bool includeHostedServices = true,
bool includeMcp = true)
{
if (includeMcp)
{
services.AddMcpServer()
.WithHttpTransport(options => options.Stateless = true)
.WithTools<NexusMcpTools>();
}
services.AddOptions<StaleTaskRecoveryOptions>()
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
@@ -364,7 +361,10 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddNpgSql(
configuration.GetConnectionString("Nexus")!,
name: "postgresql",
tags: ["database", "ready"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
return services;
+137
View File
@@ -0,0 +1,137 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.WebUtilities;
namespace Nexus.Api.Http;
public static class NexusProblemCodes
{
public const string ValidationFailed = "validation_failed";
public const string Unauthenticated = "unauthenticated";
public const string Forbidden = "forbidden";
public const string NotFound = "not_found";
public const string Conflict = "conflict";
public const string UnsupportedCapability = "unsupported_capability";
public const string DependencyUnavailable = "dependency_unavailable";
public const string Timeout = "timeout";
public const string RateLimited = "rate_limited";
public const string InternalError = "internal_error";
public static string ForStatus(int statusCode) => statusCode switch
{
StatusCodes.Status400BadRequest or StatusCodes.Status422UnprocessableEntity => ValidationFailed,
StatusCodes.Status401Unauthorized => Unauthenticated,
StatusCodes.Status403Forbidden => Forbidden,
StatusCodes.Status404NotFound => NotFound,
StatusCodes.Status409Conflict => Conflict,
StatusCodes.Status429TooManyRequests => RateLimited,
StatusCodes.Status501NotImplemented => UnsupportedCapability,
StatusCodes.Status502BadGateway or StatusCodes.Status503ServiceUnavailable => DependencyUnavailable,
StatusCodes.Status504GatewayTimeout => Timeout,
_ => InternalError
};
}
public static class NexusProblemDetailsDefaults
{
public static void Apply(ProblemDetails problem, HttpContext httpContext)
{
var statusCode = problem.Status
?? (httpContext.Response.StatusCode >= 400
? httpContext.Response.StatusCode
: StatusCodes.Status500InternalServerError);
problem.Status = statusCode;
problem.Type ??= $"https://httpstatuses.com/{statusCode}";
problem.Title ??= ReasonPhrases.GetReasonPhrase(statusCode);
problem.Extensions.TryAdd("code", NexusProblemCodes.ForStatus(statusCode));
problem.Extensions.TryAdd(
"traceId",
Activity.Current?.Id ?? httpContext.TraceIdentifier);
}
public static ProblemDetails? FromLegacyError(
object? value,
int? statusCode,
HttpContext httpContext)
{
if (value is null || statusCode is null || statusCode < 400)
return null;
var errorProperty = value.GetType().GetProperty(
"error",
System.Reflection.BindingFlags.Public
| System.Reflection.BindingFlags.Instance
| System.Reflection.BindingFlags.IgnoreCase);
if (errorProperty?.GetValue(value) is not string detail
|| string.IsNullOrWhiteSpace(detail))
{
return null;
}
var problem = new ProblemDetails
{
Status = statusCode,
Detail = detail
};
Apply(problem, httpContext);
return problem;
}
}
public static class NexusHttpResults
{
public static IResult Problem(
int statusCode,
string detail,
string? code = null,
string? title = null,
IDictionary<string, object?>? extensions = null)
{
var values = extensions is null
? new Dictionary<string, object?>()
: new Dictionary<string, object?>(extensions);
values.TryAdd("code", code ?? NexusProblemCodes.ForStatus(statusCode));
return Results.Problem(
statusCode: statusCode,
title: title,
detail: detail,
extensions: values);
}
}
/// <summary>
/// Ensures MVC-produced ProblemDetails values use the same extensions as the
/// global exception and status-code writers.
/// </summary>
public sealed class NexusProblemDetailsFilter : IResultFilter
{
public void OnResultExecuting(ResultExecutingContext context)
{
if (context.Result is not ObjectResult objectResult)
return;
if (objectResult.Value is ProblemDetails problem)
{
NexusProblemDetailsDefaults.Apply(problem, context.HttpContext);
return;
}
var legacyProblem = NexusProblemDetailsDefaults.FromLegacyError(
objectResult.Value,
objectResult.StatusCode,
context.HttpContext);
if (legacyProblem is null)
return;
objectResult.Value = legacyProblem;
objectResult.ContentTypes.Clear();
objectResult.ContentTypes.Add("application/problem+json");
}
public void OnResultExecuted(ResultExecutedContext context)
{
}
}
@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace Nexus.Api.Http;
/// <summary>
/// Publishes the stable Nexus ProblemDetails extensions in the generated
/// OpenAPI contract so the frontend does not have to guess error metadata.
/// </summary>
public sealed class NexusProblemDetailsSchemaTransformer : IOpenApiSchemaTransformer
{
public Task TransformAsync(
OpenApiSchema schema,
OpenApiSchemaTransformerContext context,
CancellationToken cancellationToken)
{
if (!typeof(ProblemDetails).IsAssignableFrom(context.JsonTypeInfo.Type))
return Task.CompletedTask;
schema.Properties ??= new Dictionary<string, IOpenApiSchema>();
schema.Properties["code"] = StringSchema(
"Stable machine-readable Nexus error code.");
schema.Properties["traceId"] = StringSchema(
"Privacy-safe server trace identifier.");
schema.Properties["operationId"] = StringSchema(
"Durable operation identifier when the request started an operation.");
schema.Properties["currentRevision"] = IntegerSchema(
"Current server revision for a stale-write conflict.");
schema.Properties["retryAfterSeconds"] = IntegerSchema(
"Minimum retry delay advertised by Nexus.");
schema.Properties["remaining"] = IntegerSchema(
"Remaining attempts when a bounded policy exposes that value.");
schema.Properties["expectedHash"] = StringSchema(
"Client-supplied content hash for a stale-write conflict.");
schema.Properties["currentHash"] = StringSchema(
"Current server content hash for a stale-write conflict.");
schema.Required ??= new HashSet<string>();
schema.Required.Add("code");
schema.Required.Add("traceId");
return Task.CompletedTask;
}
private static OpenApiSchema StringSchema(string description)
=> new()
{
Type = JsonSchemaType.String | JsonSchemaType.Null,
Description = description
};
private static OpenApiSchema IntegerSchema(string description)
=> new()
{
Type = JsonSchemaType.Integer | JsonSchemaType.Null,
Format = "int32",
Description = description
};
}
+7 -3
View File
@@ -1,4 +1,5 @@
using Nexus.Api.Extensions;
using Nexus.Api.Http;
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
@@ -20,11 +21,13 @@ builder.Services.AddNexusSwagger();
builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddNexusApplicationServices(
includeHostedServices: !isOpenApiGeneration);
includeHostedServices: !isOpenApiGeneration,
includeMcp: !isOpenApiGeneration);
builder.Services.AddNexusRepositories();
builder.Services.AddNexusHealthChecks(builder.Configuration);
builder.Services.AddNexusPlatform(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddControllers(options =>
options.Filters.Add<NexusProblemDetailsFilter>());
var app = builder.Build();
@@ -40,7 +43,8 @@ if (!isOpenApiGeneration)
// --- Middleware Pipeline ---
app.UseNexusPipeline(app.Environment);
app.MapMcp("/mcp");
if (!isOpenApiGeneration)
app.MapMcp("/mcp");
app.MapOpenApi("/openapi/{documentName}.json");
app.MapControllers();
app.Run();
@@ -0,0 +1,37 @@
namespace Nexus.Api.Security;
/// <summary>
/// Rejects browser requests that explicitly identify themselves as cross-site.
/// Requests without browser provenance headers remain valid for trusted API
/// clients; they still need the normal cookie, authentication and rate limits.
/// </summary>
public static class BrowserRequestOriginGuard
{
public static bool IsAllowed(HttpRequest request)
{
var fetchSite = request.Headers["Sec-Fetch-Site"].ToString().Trim();
if (string.Equals(fetchSite, "cross-site", StringComparison.OrdinalIgnoreCase))
return false;
var originValue = request.Headers.Origin.ToString().Trim();
if (originValue.Length == 0)
return true;
if (!Uri.TryCreate(originValue, UriKind.Absolute, out var origin))
return false;
var requestHost = request.Host.Host;
if (requestHost.Length == 0)
return false;
var originPort = origin.IsDefaultPort ? DefaultPort(origin.Scheme) : origin.Port;
var requestPort = request.Host.Port ?? DefaultPort(request.Scheme);
return string.Equals(origin.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase)
&& string.Equals(origin.Host, requestHost, StringComparison.OrdinalIgnoreCase)
&& originPort == requestPort;
}
private static int DefaultPort(string scheme)
=> string.Equals(scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) ? 443 : 80;
}
+141 -12
View File
@@ -585,18 +585,6 @@
}
}
},
"/api/v1/auth/csrf": {
"get": {
"tags": [
"Auth"
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v1/auth/login": {
"post": {
"tags": [
@@ -3023,6 +3011,21 @@
}
}
},
"/health/ready": {
"get": {
"tags": [
"Health"
],
"responses": {
"200": {
"description": "OK"
},
"503": {
"description": "Service Unavailable"
}
}
}
},
"/health": {
"get": {
"tags": [
@@ -15008,6 +15011,10 @@
}
},
"ProblemDetails": {
"required": [
"code",
"traceId"
],
"type": "object",
"properties": {
"type": {
@@ -15042,6 +15049,65 @@
"null",
"string"
]
},
"code": {
"type": [
"null",
"string"
],
"description": "Stable machine-readable Nexus error code."
},
"traceId": {
"type": [
"null",
"string"
],
"description": "Privacy-safe server trace identifier."
},
"operationId": {
"type": [
"null",
"string"
],
"description": "Durable operation identifier when the request started an operation."
},
"currentRevision": {
"type": [
"null",
"integer"
],
"description": "Current server revision for a stale-write conflict.",
"format": "int32"
},
"retryAfterSeconds": {
"type": [
"null",
"integer"
],
"description": "Minimum retry delay advertised by Nexus.",
"format": "int32"
},
"remaining": {
"type": [
"null",
"integer"
],
"description": "Remaining attempts when a bounded policy exposes that value.",
"format": "int32"
},
"expectedHash": {
"type": [
"null",
"string"
],
"description": "Client-supplied content hash for a stale-write conflict."
},
"currentHash": {
"type": [
"null",
"string"
],
"description": "Current server content hash for a stale-write conflict."
}
}
},
@@ -15899,6 +15965,10 @@
}
},
"ValidationProblemDetails": {
"required": [
"code",
"traceId"
],
"type": "object",
"properties": {
"type": {
@@ -15942,6 +16012,65 @@
"type": "string"
}
}
},
"code": {
"type": [
"null",
"string"
],
"description": "Stable machine-readable Nexus error code."
},
"traceId": {
"type": [
"null",
"string"
],
"description": "Privacy-safe server trace identifier."
},
"operationId": {
"type": [
"null",
"string"
],
"description": "Durable operation identifier when the request started an operation."
},
"currentRevision": {
"type": [
"null",
"integer"
],
"description": "Current server revision for a stale-write conflict.",
"format": "int32"
},
"retryAfterSeconds": {
"type": [
"null",
"integer"
],
"description": "Minimum retry delay advertised by Nexus.",
"format": "int32"
},
"remaining": {
"type": [
"null",
"integer"
],
"description": "Remaining attempts when a bounded policy exposes that value.",
"format": "int32"
},
"expectedHash": {
"type": [
"null",
"string"
],
"description": "Client-supplied content hash for a stale-write conflict."
},
"currentHash": {
"type": [
"null",
"string"
],
"description": "Current server content hash for a stale-write conflict."
}
}
},
+3 -3
View File
@@ -74,10 +74,10 @@ services:
- host.docker.internal:host-gateway
depends_on:
postgres:
condition: service_started
condition: service_healthy
restart: true
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/ready || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -115,7 +115,7 @@ services:
- "127.0.0.1:18880:80"
depends_on:
api:
condition: service_started
condition: service_healthy
restart: true
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
+1 -1
View File
@@ -1,6 +1,6 @@
# Nexus Agent-First Mission Control
**Status:** Zielvertrag mit produktiv ausgeliefertem v0.2.59-Control-Plane-Slice; Gateway-Writes, credentialed Live-Abnahme und Lastnachweis offen
**Status:** Zielvertrag mit produktivem v0.2.59-Control-Plane-Stand und implementiertem v0.2.60-Stabilitätskandidaten; Linux-/Docker-Releasegate, Gateway-Writes, credentialed Live-Abnahme und Lastnachweis offen
**Stand:** 2026-07-31
**Geltungsbereich:** Produkt, Frontend, Backend, Runtime-Adapter und Agenten-Schnittstellen
+33
View File
@@ -129,6 +129,31 @@ Produktionscheckpoint v0.2.59 vom 2026-07-31:
Die vollständige Evidenz und der daraus abgeleitete Plan stehen in
[Production release v0.2.59](audits/2026-07-31/production-release-v0.2.59/PRODUCTION_VALIDATION_AND_NEXT_PLAN.md).
Stabilitätscheckpoint v0.2.60 (Release Candidate) vom 2026-07-31:
- `/health/live`, `/health/ready` und `/health` trennen Prozesszustand,
PostgreSQL-Readiness und vollständige OpenClaw-Diagnose. Deploy und Rollback
verwenden dieselben Gates; ein OpenClaw-Ausfall hält die Recovery-Oberfläche
erreichbar.
- Die ungenutzte Antiforgery-Route ist entfernt. Refresh und Logout behalten
das strikte Secure-/HttpOnly-Cookie und weisen explizite Cross-Site-Browser-
Aufrufe anhand von `Origin` und `Sec-Fetch-Site` ab.
- OpenAPI beschreibt den gemeinsamen `ProblemDetails`-Vertrag einschließlich
stabilem Code und Trace-ID. Alle 20 authentifizierten Views verwenden die
gemeinsame Loading-/Empty-/Error-/Offline-/Stale-/Partial-Schicht und
erhalten sichtbare Daten bei Background-Refresh.
- `global.json`, `VERSION`-Parität, OCI-Provenienz, Browsertelemetrie-Schalter,
High/Critical-Abhängigkeitsgates, checksum-verifiziertes Gitleaks und ein
verpflichtender PostgreSQL-/Toxiproxy-CI-Job bilden den neuen Releasevertrag.
- Lokal bestehen 383 nicht-containerisierte Backendtests, 42 Frontendtests,
Typecheck, Produktionsbuild und 26 kontrollierte Playwright-Tests. Die fünf
Containerfälle sind lokal mangels Docker übersprungen und müssen im Linux-CI
mit null Skips bestehen. Credentialed Produktion, Task-Board-Last und echte
OpenClaw-Schreibpfade bleiben eigene Gates.
Die genaue Implementierungs- und Evidenzgrenze steht in
[Stability v0.2.60](audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md).
## 4. Prioritätsdefinition
- **P0:** Blockiert sicheren End-to-End-Betrieb oder das Kernversprechen
@@ -450,6 +475,14 @@ regressionsfest.
- Last-, Reconnect-, Timeout-, Queue-, Recovery- und Chaos-Szenarien prüfen.
- Runbooks, Backup/Restore und Rollback regelmäßig als Game Day testen.
**Checkpoint 2026-07-31:** Das v0.2.60-Stabilitätsfundament implementiert
separate Readiness, einen generierten Fehlervertrag, gemeinsame UI-Recovery,
ein SDK-/Versionsgate, Supply-Chain-Scans und verpflichtende Containerverträge.
Der kontrollierte Browserlauf deckt alle 20 authentifizierten Views und fünf
Breiten ab. Offen bleiben der grüne Linux-/Docker-Run, credentialed
Produktionsprüfung, der 1.000/10.000-Task-Board-Lastnachweis sowie reale
OpenClaw-/OpenAI-End-to-End-Evidenz.
**Abnahme:** Ein Release ist nur möglich, wenn technische Checks und ein
repräsentativer realer Agenten-Workflow gemeinsam grün sind.
+38
View File
@@ -10,6 +10,44 @@ OpenClaw, PostgreSQL, browser, or load-test boundary. Archive the command,
versions, sanitized output, dataset provenance, and timestamp for every
acceptance run.
## Release CI gates
Every push now runs four independent pre-deployment jobs:
- the normal .NET 10 build and test suite plus a High/Critical NuGet
vulnerability gate;
- a mandatory Linux runner job with both
`NEXUS_RUN_DOCKER_INTEGRATION_TESTS=true` and
`NEXUS_RUN_TOXIPROXY_INTEGRATION_TESTS=true`;
- frontend version parity, High/Critical production dependency audit,
typecheck, generated OpenAPI drift, unit tests, production build and the full
Playwright route matrix; and
- a full-history Gitleaks v8.30.1 scan downloaded from the upstream release and
checked against its pinned SHA-256 before execution.
Deployment depends on all four jobs. A missing Docker endpoint is therefore a
failing integration job, not a successful skip. Local runs without Docker may
still show five explicit skips, but they are not release acceptance evidence.
The checked-in `.gitleaksignore` contains only exact fingerprints for reviewed
historical findings; it is not a pattern-based bypass for new secrets.
The repository SDK contract is `global.json`: .NET `10.0.100` with
`latestFeature` roll-forward. `VERSION` is the release source of truth;
frontend package version and OCI image labels are checked against it.
## Browser route and recovery gate
The fixture Playwright profile sets `VITE_BROWSER_TELEMETRY_ENABLED=false`, so
expected telemetry proxy failures cannot mask application regressions.
Production container builds enable the allow-listed browser metrics explicitly.
The route suite covers Login plus all 20 authenticated views at 375, 768, 1024,
1440 and 1920 px. It also checks deep links, shared-query deduplication, one
targeted resync after an SSE sequence gap, retained Task Board content during a
refresh, Done pagination, drag-and-drop, and distinguishable dependency
outage/retry recovery. These are controlled browser contracts, not a
credentialed production or real OpenClaw acceptance run.
## Task Board load gate
The full k6 profile holds ten virtual users for two minutes and fails when:
+6
View File
@@ -1,5 +1,11 @@
# Nexus Security Spot Check
> **Historische Evidenz:** Diese Datei hält den Stand vom 2026-07-26 fest. Der
> v0.2.60-Stabilitätsslice entfernte die ungenutzte CSRF-Tokenroute, ergänzte
> Browser-Origin-Prüfungen für Refresh/Logout und ersetzte den einfachen CI-Grep
> durch checksum-gepinntes Full-History-Gitleaks. Aktuelle Abnahmegrenzen stehen
> in `docs/audits/2026-07-31/stability-v0.2.60/IMPLEMENTATION_AND_ACCEPTANCE.md`.
**Datum:** 2026-07-26
**Commit:** `3bc7622977f4a6c2f2e98ab4aa856a2e45c3cf49`
**Vertraulichkeit:** Intern
@@ -184,7 +184,9 @@ Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
- JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens)
- Refresh-Token-Reuse-Detection verhindert Token-Theft
- Rate-Limiting auf Login und Refresh
- CSRF-Protection via `X-CSRF-TOKEN` + `nexus-csrf` Cookie
- Cookie-backed Refresh/Logout über `Secure`, `HttpOnly`, `SameSite=Strict`
plus `Origin`-/`Sec-Fetch-Site`-Prüfung; die unvalidierte Legacy-CSRF-Route
wurde in v0.2.60 entfernt
- Security Headers (HSTS, CSP, XFO, Referrer-Policy)
**Kritisch:**
@@ -0,0 +1,188 @@
# Nexus v0.2.60 — Stabilität und Recovery
**Stand:** 2026-07-31
**Status:** Release Candidate; lokale Verträge grün, Linux-/Docker-CI und
Produktionsdeployment noch auszuführen
**Scope:** Nexus-Repository und kontrollierte Browser-Fixtures. Keine
produktive OpenClaw-Mutation; Maxis Ressourcen lagen vollständig außerhalb des
Prüfbereichs.
## Ergebnis
Der erste Stabilitäts- und Fehlerabbau-Slice ist implementiert. Nexus trennt
jetzt Prozess-Liveness, Datenbank-Readiness und vollständige Runtime-Diagnose,
liefert einen generierten gemeinsamen Fehlervertrag und zeigt auf allen 20
authentifizierten Views einheitliche Loading-, Empty-, Error-, Offline-, Stale-
und Partial-Zustände. Releaseversion, Containerprovenienz, Dependency- und
Secret-Scans sowie die echten PostgreSQL-/Toxiproxy-Verträge sind Teil der
CI-Freigabe.
Das ist noch keine vollständige Produktions- oder OpenClaw-Abnahme. Der
credentialed Owner-Audit, Task-Board-Lastnachweis und jeder produktive
OpenClaw-Schreibvorgang bleiben getrennte Gates.
## Implementierter Vertrag
### Health, Deployment und Version
- `GET /health/live` ist ein reiner Prozesscheck.
- `GET /health/ready` prüft ausschließlich Pflichtabhängigkeiten mit dem Tag
`ready`; PostgreSQL-Ausfall liefert HTTP 503.
- `GET /health` bleibt die vollständige Diagnose. Ein OpenClaw-Ausfall ergibt
`Degraded`, ohne die Nexus-Recovery-Oberfläche durch Readiness zu sperren.
- Der API-Container wird über `/health/ready` geprüft. Web wartet auf einen
gesunden API-Container; API wartet auf gesundes PostgreSQL.
- Deployment prüft zuerst die lokale Container-Readiness, danach öffentliche
Readiness und den vollständigen Runtimezustand. Rollback verwendet dieselben
Gates und akzeptiert HTTP 404 für `/health/ready` nur bei einem sichtbaren
Notfall-Rollback auf Versionen vor v0.2.60.
- `global.json` verlangt .NET `10.0.100` mit `latestFeature`-Roll-forward.
`VERSION` ist die Releasequelle; Frontendpaket und OCI-Labels werden dagegen
geprüft. Images tragen Version und exakten Git-SHA.
### Auth und Browsergrenze
- Die ungenutzte `/api/v1/auth/csrf`-Route und Antiforgery-Registrierung sind
entfernt. Kein Client sendete das Token und keine Mutation validierte es.
- Das Refresh-Cookie bleibt `Secure`, `HttpOnly` und `SameSite=Strict`.
- Refresh und Logout weisen explizite Cross-Site-Browseraufrufe anhand von
`Origin` und `Sec-Fetch-Site` ab. API-Clients ohne Browser-Provenienzheader
benötigen weiterhin das gültige Cookie und unterliegen den normalen Limits.
- Login-, Refresh- und Rate-Limit-Fehler verwenden denselben strukturierten
Problemvertrag.
- Nach einem fehlgeschlagenen Refresh leitet das Frontend genau einmal zum
Login um und erhält das Rückkehrziel.
- Ein persistierter Refresh-Hash bleibt über eine neu erzeugte Serviceinstanz
verwendbar und wird anschließend rotiert. Ein echter Containerneustart wird
zusätzlich im produktionsnahen Releaseprofil geprüft.
### Gemeinsame Fehler- und Recovery-Schicht
Backendfehler verwenden `application/problem+json` mit:
- `code`, `status`, `title`, `detail`, `traceId`;
- optional `operationId`, `currentRevision`, `retryAfterSeconds`, `remaining`;
- den stabilen Codes `validation_failed`, `unauthenticated`, `forbidden`,
`conflict`, `not_found`, `unsupported_capability`,
`dependency_unavailable`, `timeout`, `rate_limited` und `internal_error`.
Durable Agent-Proposal-, Run- und andere Operationsendpunkte behalten ihre
typisierten Envelopes für Zustände wie `partial`, `failed` und `in_doubt`.
Diese Zustände müssen dauerhaft untersuchbar bleiben und werden nicht als
flüchtige HTTP-Ausnahme versteckt. Der Frontendadapter normalisiert während der
Kompatibilitätsphase zusätzlich ältere `message`-/`error`-Payloads.
Ein OpenAPI-Schema-Transformer beschreibt diese Erweiterungen im
eingecheckten 3.1-Vertrag. Der generierte TypeScript-Client speist `AppProblem`
und `toAppProblem`; Views raten die Transportstruktur nicht selbst.
`AsyncStatePanel` stellt Loading, Empty, Error, Offline, Stale und Partial
semantisch dar, zeigt nur technische Trace-/Operation-Metadaten und bietet die
passende Recovery-Aktion. Sichere GETs dürfen begrenzt wiederholt werden;
Mutationen werden nie automatisch wiederholt. Sichtbare Daten bleiben bei
Background-Refresh oder einem Fehler einer sekundären Detailabfrage erhalten.
Migriert wurden:
- Dashboard und Task Strip;
- Agents, Agent Detail, Agent Create und Proposal Detail;
- Projects, Project Detail, Task Board und Task Detail;
- Run Control und Run Detail;
- Calendar, Memory, Docs und Incidents;
- Models, Activity, Notifications, Security und Settings.
Memory, Docs und Incidents verlieren ihre bereits sichtbare Liste nicht mehr,
wenn nur eine Detail- oder Suchabfrage fehlschlägt. Agent Detail blockiert die
Primäransicht nicht länger wegen einer fehlerhaften Sekundärabfrage.
### CI und Supply Chain
- Ein separater verpflichtender Linux-Job führt alle als
`DockerIntegration` oder `ToxiproxyIntegration` markierten Tests mit beiden
Opt-ins aus. Fehlendes Docker ist ein Jobfehler, kein Skip.
- Frontend und Backend blockieren High/Critical-Produktionsabhängigkeiten.
- Gitleaks `8.30.1` wird als Upstream-Artefakt geladen, über einen gepinnten
SHA-256 geprüft und gegen die vollständige Git-Historie ausgeführt.
- Drei überprüfte historische Fingerprints sind exakt baselined; die aktuelle
Arbeitskopie ist redigiert. Eine eventuelle Credential-Rotation oder
History-Rewrite bleibt ein gesonderter, ausdrücklich freizugebender
Sicherheitsvorgang.
- Das Fixture-Playwright-Profil deaktiviert Browsertelemetrie. Das
Produktionsimage aktiviert nur den allow-listeten bestehenden
Browsermetrikpfad über `VITE_BROWSER_TELEMETRY_ENABLED=true`.
## Lokale Abnahme
| Gate | Ergebnis |
|---|---|
| Frontend Typecheck | grün |
| Frontend Unit Tests | 13 Dateien, 42 Tests bestanden |
| Frontend Production Build | grün |
| Playwright | 26/26 bestanden |
| Geschützte Routen | alle 20 in kontrollierten Fixtures geprüft |
| Viewports | 375, 768, 1024, 1440 und 1920 px ohne Seitenoverflow |
| Browserverträge | Deep Links, Query-Deduplizierung, ein SSE-Resync, Task-Board-Refresh, Done-Pagination, Drag-and-drop und 503-Recovery grün |
| Backend | 383 bestanden; fünf Containerfälle mangels lokalem Docker explizit übersprungen |
| PostgreSQL-Ausfall | echter Npgsql-Healthcheck gegen einen nicht erreichbaren Endpoint liefert im Readiness-Vertrag 503 |
| Version | `VERSION` und Frontendpaket beide `0.2.60`; .NET-Pin wird als 10.0.101 aus dem erlaubten Feature-Band aufgelöst |
| Abhängigkeiten | keine bekannten pnpm-Produktionslücken; keine High/Critical-NuGet-Funde |
| Compose/Workflows | Compose valide, Deploy-Shell syntaktisch valide, CI- und Rollback-YAML parsebar |
| OpenAPI | neu generiert; entfernte CSRF-Route, neue Readiness-Route und Problemfelder enthalten |
Playwright arbeitet mit kontrollierten API-Fixtures. Diese Ergebnisse beweisen
die UI- und Transportverträge, nicht die echte Produktionsdatenqualität oder
OpenClaw-/OpenAI-Ausführung.
## Offene Abnahmegates
### Vor Deployment
1. Der neue Gitea-Linuxjob muss alle fünf vorhandenen PostgreSQL-/Toxiproxy-
Containerfälle bestehen, null überspringen und Docker als erreichbar melden.
2. Der Gitleaks-Vollhistorienjob, OpenAPI-Diff, Dependency-Gates und die übrigen
Backend-/Frontendjobs müssen grün sein.
3. Das versionierte Image muss mit SHA-Provenienz gebaut und durch dieselben
Readiness-/Diagnosegates deployt werden.
### Nach Deployment, ohne Owner-Sitzung
- `/health/live`, `/health/ready`, `/health`, `/login`, Security-Header und ein
unauthentifizierter geschützter API-Aufruf werden öffentlich geprüft.
- Die Produktion muss `v0.2.60` und den ausgelieferten Git-SHA melden.
### Mit separater Owner-Sitzung
- Alle 20 authentifizierten Produktionsviews werden read-only auf Navigation,
Requests, Konsole, Refresh/Reload/Logout, echte Agent-/Cron-/Modell-/Security-
Daten, SSE-Freshness, Tastatur und Overflow geprüft.
- Kein OpenClaw-Schreibtest wird dabei ausgeführt.
### Noch nicht Teil dieses Release-Slices
- Task Board mit 1.000 Tasks und 10.000 Activities: k6-p95,
SQL-Statementzählung, `EXPLAIN (ANALYZE, BUFFERS)` und wiederholte
Navigation-bis-Karten-sichtbar-Messung.
- Produktive Protocol-v4-Verbindung und Management. OpenClaw `2026.7.1`
registriert weiterhin keine offiziell unterstützte externe Nexus-/Generic-
Operator-ID. Die neuen offiziellen Seiten zu
[Gateway clients](https://docs.openclaw.ai/gateway/clients) und
[external apps](https://docs.openclaw.ai/gateway/external-apps) ändern den
geschlossenen Connect-Schema-/Client-ID-Vertrag noch nicht.
- Ein echter `Nexus -> OpenClaw -> OpenAI -> Nexus`-Run, Wegwerf-Cron oder
Testagent. Diese Mutationen benötigen nach erfülltem Identity-Gate eine
separate Owner-Freigabe.
## Nächste Reihenfolge
1. Linux-/Docker-CI und Deployment-Smoke für v0.2.60 abschließen.
2. Bao meldet sich im In-App-Browser als Owner an; danach 20 Seiten read-only
auditieren und jeden Fund mit Route, API, Trace-ID und Regressionstest
erfassen.
3. Task-Board-Datensatz und Last-/SQL-/Browserbudgets isoliert beweisen; nur
gemessene Engpässe optimieren.
4. OpenClaw-Client-ID auf dem ersten kompatiblen Stable-Tag erneut prüfen und
erst dann read-only pairen.
5. Danach Run Explorer, Agent Lifecycle, Notifications/Incidents, Calendar,
Knowledge und Security in der bestehenden Roadmap-Reihenfolge schließen.
+5
View File
@@ -1,4 +1,9 @@
FROM node:24-alpine AS build
ARG NEXUS_VERSION=dev
ARG NEXUS_GIT_SHA=unknown
ENV VITE_BUILD_VERSION=${NEXUS_VERSION}
ENV VITE_BUILD_SHA=${NEXUS_GIT_SHA}
ENV VITE_BROWSER_TELEMETRY_ENABLED=true
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml* ./
+27
View File
@@ -131,6 +131,33 @@ test.describe('authenticated route and deep-link smoke', () => {
})).toBeVisible()
await expect.poll(() => api.openClawOverviewRequestCount).toBe(1)
expect(api.browserTelemetryRequestCount).toBe(0)
})
test('distinguishes a dependency outage and recovers without hiding the route', async ({ page }, testInfo) => {
await mockNexusApi(page, {
role: 'owner',
apiFailure: {
path: '/api/v1/projects',
status: 503,
// Vue Query performs two bounded retries for safe reads. The visible
// recovery action is the next, explicit request.
attempts: 3,
},
})
await page.goto('/projects')
const recovery = page.locator('[data-state="offline"][data-problem-kind="offline"]')
await expect(recovery).toBeVisible()
await expect(recovery).toContainText('Abhängigkeit nicht erreichbar')
await page.screenshot({
path: testInfo.outputPath('dependency-recovery.png'),
fullPage: true,
})
await recovery.getByRole('button', { name: 'Erneut prüfen' }).click()
await expect(page.getByRole('link', { name: /Release Readiness/ })).toBeVisible()
await expect(recovery).toHaveCount(0)
})
test('keeps Iris and run mutation entry points unavailable to non-owners', async ({ page }) => {
+29
View File
@@ -23,6 +23,11 @@ interface MockOptions {
domainResyncSequence?: number
boardRefreshDelayMs?: number
boardRefreshTitle?: string
apiFailure?: {
path: string
status: number
attempts?: number
}
}
interface CapturedRequest {
@@ -40,6 +45,7 @@ export interface NexusApiHarness {
readonly chatRequests: CapturedRequest[]
readonly resyncResponseCount: number
readonly openClawOverviewRequestCount: number
readonly browserTelemetryRequestCount: number
readonly proposal: Record<string, unknown>
}
@@ -453,6 +459,8 @@ export async function mockNexusApi(
let resyncResponseCount = 0
let boardInitialRequestCount = 0
let openClawOverviewRequestCount = 0
let browserTelemetryRequestCount = 0
let injectedFailureCount = 0
let openTaskState = 'Backlog'
await page.route('**/api/**', async route => {
@@ -469,6 +477,23 @@ export async function mockNexusApi(
return
}
if (
options.apiFailure
&& path === options.apiFailure.path
&& injectedFailureCount < (options.apiFailure.attempts ?? 1)
) {
injectedFailureCount += 1
await fulfillJson(
route,
problem(
'The requested Nexus dependency is temporarily unavailable.',
options.apiFailure.status,
),
options.apiFailure.status,
)
return
}
if (path === '/api/v1/auth/refresh' && method === 'POST') {
if (!authenticated) {
await fulfillJson(route, problem('No active E2E session.', 401), 401)
@@ -861,6 +886,7 @@ export async function mockNexusApi(
return
}
if (path === '/api/v1/telemetry/browser') {
browserTelemetryRequestCount += 1
await route.fulfill({ status: 202 })
return
}
@@ -1192,6 +1218,9 @@ export async function mockNexusApi(
get openClawOverviewRequestCount() {
return openClawOverviewRequestCount
},
get browserTelemetryRequestCount() {
return browserTelemetryRequestCount
},
get proposal() {
return proposal
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "nexus-web",
"private": true,
"version": "0.1.0",
"version": "0.2.60",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+3
View File
@@ -33,6 +33,9 @@ export default defineConfig({
webServer: {
command: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
url: baseURL,
env: {
VITE_BROWSER_TELEMETRY_ENABLED: 'false',
},
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
+2 -6
View File
@@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { components } from './generated/schema'
import { apiClient } from './client'
import { ApiProblem, type ProblemDetailsDto } from './contracts'
import { ApiProblem, asProblemDetails } from './contracts'
import { queryKeys } from './queryClient'
import { createMutationRequestContext } from '../services/mutationContext'
@@ -73,12 +73,8 @@ const STATUS_META: Record<string, AgentProposalStatusMeta> = {
},
}
function asProblem(value: unknown): ProblemDetailsDto | null {
return value && typeof value === 'object' ? value as ProblemDetailsDto : null
}
function fail(response: Response, error: unknown, fallback: string): never {
throw new ApiProblem(response.status, asProblem(error), fallback)
throw new ApiProblem(response.status, asProblemDetails(error, response.status), fallback)
}
export function getAgentProposalStatusMeta(status: string): AgentProposalStatusMeta {
+145 -9
View File
@@ -1,3 +1,5 @@
import type { components } from './generated/schema'
export type EntityType =
| 'activity'
| 'agent'
@@ -57,37 +59,171 @@ export function normalizeOperationResult(
}
}
export interface ProblemDetailsDto {
type?: string
title?: string
export type ProblemDetailsDto = Omit<
components['schemas']['ProblemDetails'],
| 'status'
| 'code'
| 'traceId'
| 'operationId'
| 'currentRevision'
| 'retryAfterSeconds'
| 'remaining'
> & {
status?: number
detail?: string
instance?: string
code?: string
traceId?: string
operationId?: string
currentRevision?: number
retryAfterSeconds?: number
remaining?: number
errors?: Record<string, string[]>
}
export class ApiProblem extends Error {
export type AppProblemKind =
| 'validation'
| 'authentication'
| 'permission'
| 'conflict'
| 'unsupported'
| 'offline'
| 'timeout'
| 'rate-limit'
| 'not-found'
| 'internal'
export type AppRecoveryAction =
| 'retry'
| 'reauthenticate'
| 'reload-conflict'
| 'inspect-connection'
| 'none'
export class AppProblem extends Error {
readonly status: number
readonly problem: ProblemDetailsDto | null
readonly code: string
readonly kind: AppProblemKind
readonly traceId: string | null
readonly operationId: string | null
readonly currentRevision: number | null
readonly retryAfterSeconds: number | null
readonly recoveryAction: AppRecoveryAction
constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) {
super(problem?.detail || problem?.title || fallback)
this.name = 'ApiProblem'
this.name = 'AppProblem'
this.status = status
this.problem = problem
this.code = problem?.code || codeForStatus(status)
this.kind = kindForCode(this.code, status)
this.traceId = problem?.traceId || null
this.operationId = problem?.operationId || null
this.currentRevision = finiteNumber(problem?.currentRevision)
this.retryAfterSeconds = finiteNumber(problem?.retryAfterSeconds)
this.recoveryAction = recoveryForKind(this.kind)
}
}
/** @deprecated Prefer AppProblem. Kept while API modules migrate. */
export class ApiProblem extends AppProblem {
constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) {
super(status, problem, fallback)
this.name = 'ApiProblem'
}
}
function finiteNumber(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null
const normalized = Number(value)
return Number.isFinite(normalized) ? normalized : null
}
function codeForStatus(status: number): string {
if (status === 400 || status === 422) return 'validation_failed'
if (status === 401) return 'unauthenticated'
if (status === 403) return 'forbidden'
if (status === 404) return 'not_found'
if (status === 409) return 'conflict'
if (status === 429) return 'rate_limited'
if (status === 501) return 'unsupported_capability'
if (status === 502 || status === 503 || status === 0) return 'dependency_unavailable'
if (status === 504) return 'timeout'
return 'internal_error'
}
function kindForCode(code: string, status: number): AppProblemKind {
if (code === 'validation_failed') return 'validation'
if (code === 'unauthenticated' || status === 401) return 'authentication'
if (code === 'forbidden' || status === 403) return 'permission'
if (code === 'conflict' || status === 409) return 'conflict'
if (code === 'unsupported_capability' || status === 501) return 'unsupported'
if (code === 'dependency_unavailable' || status === 0 || status === 502 || status === 503) return 'offline'
if (code === 'timeout' || status === 504) return 'timeout'
if (code === 'rate_limited' || status === 429) return 'rate-limit'
if (code === 'not_found' || status === 404) return 'not-found'
return status >= 500 ? 'internal' : 'validation'
}
function recoveryForKind(kind: AppProblemKind): AppRecoveryAction {
if (kind === 'authentication') return 'reauthenticate'
if (kind === 'conflict') return 'reload-conflict'
if (kind === 'offline' || kind === 'unsupported') return 'inspect-connection'
if (kind === 'permission' || kind === 'not-found') return 'none'
return 'retry'
}
export function asProblemDetails(value: unknown, status?: number): ProblemDetailsDto | null {
if (!value || typeof value !== 'object') return null
const source = value as Record<string, unknown>
const detail = typeof source.detail === 'string'
? source.detail
: typeof source.message === 'string'
? source.message
: typeof source.error === 'string'
? source.error
: undefined
const normalizedStatus = finiteNumber(source.status) ?? status
return {
...(source as ProblemDetailsDto),
status: normalizedStatus ?? undefined,
detail,
code: typeof source.code === 'string' ? source.code : undefined,
traceId: typeof source.traceId === 'string' ? source.traceId : undefined,
}
}
export function toAppProblem(error: unknown, fallback = 'Die Anfrage ist fehlgeschlagen.'): AppProblem {
if (error instanceof AppProblem) return error
if (error instanceof DOMException && error.name === 'AbortError') {
return new AppProblem(504, {
code: 'timeout',
detail: 'Die Anfrage wurde abgebrochen oder hat ihr Zeitlimit überschritten.',
}, fallback)
}
if (error instanceof TypeError) {
return new AppProblem(0, {
code: 'dependency_unavailable',
detail: error.message || 'Nexus konnte den Dienst nicht erreichen.',
}, fallback)
}
if (error instanceof Error) {
return new AppProblem(500, {
code: 'internal_error',
detail: error.message,
}, fallback)
}
return new AppProblem(500, { code: 'internal_error', detail: fallback }, fallback)
}
export async function throwApiProblem(response: Response, fallback: string): Promise<never> {
let problem: ProblemDetailsDto | null = null
try {
problem = await response.json() as ProblemDetailsDto
problem = asProblemDetails(await response.json(), response.status)
} catch {
// A structured ProblemDetails response is preferred, but legacy endpoints
// can still return an empty error body during the migration.
}
throw new ApiProblem(response.status, problem, fallback)
}
import type { components } from './generated/schema'
+90 -33
View File
@@ -518,39 +518,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/auth/csrf": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/login": {
parameters: {
query?: never;
@@ -2767,6 +2734,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/health/ready": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Service Unavailable */
503: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/health": {
parameters: {
query?: never;
@@ -8207,6 +8214,31 @@ export interface components {
status?: null | number | string;
detail?: null | string;
instance?: null | string;
/** @description Stable machine-readable Nexus error code. */
code: null | string;
/** @description Privacy-safe server trace identifier. */
traceId: null | string;
/** @description Durable operation identifier when the request started an operation. */
operationId?: null | string;
/**
* Format: int32
* @description Current server revision for a stale-write conflict.
*/
currentRevision?: null | number;
/**
* Format: int32
* @description Minimum retry delay advertised by Nexus.
*/
retryAfterSeconds?: null | number;
/**
* Format: int32
* @description Remaining attempts when a bounded policy exposes that value.
*/
remaining?: null | number;
/** @description Client-supplied content hash for a stale-write conflict. */
expectedHash?: null | string;
/** @description Current server content hash for a stale-write conflict. */
currentHash?: null | string;
};
ProjectDto: {
/** Format: uuid */
@@ -8422,6 +8454,31 @@ export interface components {
errors?: {
[key: string]: string[];
};
/** @description Stable machine-readable Nexus error code. */
code: null | string;
/** @description Privacy-safe server trace identifier. */
traceId: null | string;
/** @description Durable operation identifier when the request started an operation. */
operationId?: null | string;
/**
* Format: int32
* @description Current server revision for a stale-write conflict.
*/
currentRevision?: null | number;
/**
* Format: int32
* @description Minimum retry delay advertised by Nexus.
*/
retryAfterSeconds?: null | number;
/**
* Format: int32
* @description Remaining attempts when a bounded policy exposes that value.
*/
remaining?: null | number;
/** @description Client-supplied content hash for a stale-write conflict. */
expectedHash?: null | string;
/** @description Current server content hash for a stale-write conflict. */
currentHash?: null | string;
};
VerifyOpenClawRequest: {
/** Format: int32 */
+2 -6
View File
@@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import type { paths } from './generated/schema'
import { apiClient } from './client'
import { ApiProblem, type ProblemDetailsDto } from './contracts'
import { ApiProblem, asProblemDetails } from './contracts'
import { queryClient, queryKeys } from './queryClient'
import type { AgentNodeData } from '../composables/useFlowLayout'
@@ -27,12 +27,8 @@ export type OpenClawCapabilitiesDto = GeneratedCapabilities
export type OpenClawAgentsDto = GeneratedAgents
type OpenClawAgentDto = OpenClawAgentsDto['items'][number]
function asProblem(value: unknown): ProblemDetailsDto | null {
return value && typeof value === 'object' ? value as ProblemDetailsDto : null
}
function fail(response: Response, error: unknown, fallback: string): never {
throw new ApiProblem(response.status, asProblem(error), fallback)
throw new ApiProblem(response.status, asProblemDetails(error, response.status), fallback)
}
export async function fetchOpenClawOverview(
@@ -2,13 +2,16 @@
import { ListTodo } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import type { TaskItem } from './types'
import AsyncStatePanel from '../../mission-control/AsyncStatePanel.vue'
defineProps<{
tasks: TaskItem[]
loading?: boolean
error?: string | null
error?: unknown
}>()
defineEmits<{ retry: [] }>()
function priorityLabel(priority: TaskItem['priority']): string {
return priority === 'high' ? 'P0' : priority === 'medium' ? 'P1' : 'P2'
}
@@ -37,14 +40,35 @@ function taskLabel(task: TaskItem): string {
<span>Fokus</span>
</div>
<div v-if="loading" class="task-list" aria-label="Tasks werden geladen">
<span v-for="index in 3" :key="index" class="tcard skeleton"></span>
</div>
<AsyncStatePanel
v-if="loading && !tasks.length"
class="tstrip-state"
state="loading"
title="Fokus-Tasks werden geladen"
compact
inline
/>
<div v-else-if="error" class="tstrip-msg error">{{ error }}</div>
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
<AsyncStatePanel
v-else-if="error && !tasks.length"
class="tstrip-state"
state="error"
title="Fokus-Tasks nicht verfügbar"
:problem="error"
compact
inline
@action="$emit('retry')"
/>
<AsyncStatePanel
v-else-if="!tasks.length"
class="tstrip-state"
state="empty"
title="Keine aktiven Tasks"
compact
inline
/>
<div v-else class="task-list">
<div v-else class="task-list" :aria-busy="loading">
<RouterLink
v-for="task in tasks.slice(0, 4)"
:key="task.id"
@@ -102,6 +126,11 @@ function taskLabel(task: TaskItem): string {
overflow: hidden;
}
.tstrip-state {
min-width: 0;
flex: 1;
}
.tcard {
min-width: 0;
flex: 1 1 0;
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
AlertTriangle,
CircleOff,
CloudOff,
FileQuestion,
Loader2,
RefreshCw,
} from '@lucide/vue'
import { toAppProblem, type AppProblem } from '../../api/contracts'
export type AsyncStateKind = 'loading' | 'empty' | 'error' | 'offline' | 'stale' | 'partial'
const props = withDefaults(defineProps<{
state: AsyncStateKind
title?: string
message?: string
problem?: unknown
actionLabel?: string
busy?: boolean
compact?: boolean
inline?: boolean
}>(), {
title: '',
message: '',
problem: undefined,
actionLabel: '',
busy: false,
compact: false,
inline: false,
})
defineEmits<{ action: [] }>()
const normalizedProblem = computed<AppProblem | null>(() =>
props.problem === undefined ? null : toAppProblem(props.problem),
)
const resolvedState = computed<AsyncStateKind>(() =>
props.state === 'error' && normalizedProblem.value?.kind === 'offline'
? 'offline'
: props.state,
)
const problemLabel = computed(() => {
const kind = normalizedProblem.value?.kind
if (kind === 'authentication') return 'Sitzung abgelaufen'
if (kind === 'permission') return 'Keine Berechtigung'
if (kind === 'conflict') return 'Versionskonflikt'
if (kind === 'unsupported') return 'Capability fehlt'
if (kind === 'offline') return 'Abhängigkeit nicht erreichbar'
if (kind === 'timeout') return 'Zeitüberschreitung'
if (kind === 'rate-limit') return 'Rate Limit'
if (kind === 'not-found') return 'Nicht gefunden'
if (kind === 'validation') return 'Eingabe ungültig'
if (kind === 'internal') return 'Interner Fehler'
return ''
})
const presentation = computed(() => {
const problem = normalizedProblem.value
const defaults = {
loading: ['Daten werden geladen', 'Nexus synchronisiert den aktuellen Stand.'],
empty: ['Noch keine Daten', 'Für diesen Bereich liegen derzeit keine Einträge vor.'],
error: ['Daten konnten nicht geladen werden', problem?.message || 'Die Anfrage ist fehlgeschlagen.'],
offline: ['Verbindung nicht verfügbar', problem?.message || 'Nexus kann den abhängigen Dienst derzeit nicht erreichen.'],
stale: ['Daten möglicherweise veraltet', 'Die letzte bestätigte Momentaufnahme bleibt sichtbar.'],
partial: ['Daten teilweise verfügbar', 'Nexus zeigt den bestätigten Teilbestand und hält fehlende Bereiche sichtbar.'],
} satisfies Record<AsyncStateKind, [string, string]>
return {
title: props.title || defaults[props.state][0],
message: props.message || defaults[props.state][1],
}
})
const resolvedActionLabel = computed(() => {
if (props.actionLabel) return props.actionLabel
const action = normalizedProblem.value?.recoveryAction
if (action === 'reauthenticate') return 'Neu anmelden'
if (action === 'reload-conflict') return 'Aktuellen Stand laden'
if (action === 'inspect-connection') return 'Erneut prüfen'
if (action === 'retry') return 'Erneut versuchen'
return ''
})
const icon = computed(() => {
if (resolvedState.value === 'loading') return Loader2
if (resolvedState.value === 'empty') return FileQuestion
if (resolvedState.value === 'offline') return CloudOff
if (resolvedState.value === 'stale' || resolvedState.value === 'partial') return CircleOff
return AlertTriangle
})
const liveRole = computed(() =>
resolvedState.value === 'error' || resolvedState.value === 'offline' ? 'alert' : 'status',
)
</script>
<template>
<section
class="async-state nexus-state"
:class="[`nexus-state--${resolvedState}`, problemLabel ? `nexus-state--problem-${normalizedProblem?.kind}` : '', { 'async-state--compact': compact, 'async-state--inline': inline }]"
:role="liveRole"
:aria-busy="resolvedState === 'loading' || busy"
:data-state="resolvedState"
:data-problem-kind="normalizedProblem?.kind"
>
<component
:is="icon"
:size="inline ? 14 : compact ? 18 : 22"
:class="{ spin: resolvedState === 'loading' || busy }"
aria-hidden="true"
/>
<div class="async-state__body">
<span v-if="problemLabel" class="async-state__kind">{{ problemLabel }}</span>
<strong>{{ presentation.title }}</strong>
<p>{{ presentation.message }}</p>
<details v-if="normalizedProblem?.traceId || normalizedProblem?.operationId" class="async-state__details">
<summary>Technische Details</summary>
<span v-if="normalizedProblem.traceId">Trace {{ normalizedProblem.traceId }}</span>
<span v-if="normalizedProblem.operationId">Operation {{ normalizedProblem.operationId }}</span>
</details>
</div>
<button
v-if="resolvedActionLabel"
type="button"
class="nexus-button"
:disabled="busy"
@click="$emit('action')"
>
<Loader2 v-if="busy" :size="14" class="spin" aria-hidden="true" />
<RefreshCw v-else :size="14" aria-hidden="true" />
{{ resolvedActionLabel }}
</button>
</section>
</template>
<style scoped>
.async-state {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
width: 100%;
min-height: 112px;
padding: 18px;
text-align: left;
}
.async-state--compact {
min-height: 72px;
padding: 12px 14px;
}
.async-state--inline {
min-height: 32px;
padding: 4px 8px;
gap: 7px;
}
.async-state--inline .async-state__body strong {
overflow: hidden;
font-family: var(--font-body, 'Manrope', sans-serif);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.async-state--inline .async-state__body p,
.async-state--inline .async-state__details,
.async-state--inline .async-state__kind {
display: none;
}
.async-state--inline .nexus-button {
min-height: 24px;
padding: 3px 7px;
font-size: 10px;
}
.async-state__body {
min-width: 0;
}
.async-state__body strong {
display: block;
color: var(--tx);
font-family: 'Space Grotesk', sans-serif;
font-size: 14px;
line-height: 1.35;
}
.async-state__kind {
display: block;
margin-bottom: 3px;
color: var(--tx-3);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.async-state__body p {
margin: 4px 0 0;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 12px;
line-height: 1.55;
}
.async-state__details {
margin-top: 8px;
color: var(--tx-3);
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.async-state__details summary {
cursor: pointer;
}
.async-state__details span {
display: block;
overflow-wrap: anywhere;
margin-top: 4px;
}
@media (max-width: 560px) {
.async-state {
grid-template-columns: auto minmax(0, 1fr);
}
.async-state .nexus-button {
grid-column: 1 / -1;
justify-self: start;
}
}
</style>
+13 -1
View File
@@ -1,5 +1,17 @@
import { useAuthStore } from '../stores/auth'
let loginRedirectStarted = false
function redirectToLogin(): void {
if (loginRedirectStarted || window.location.pathname === '/login') return
loginRedirectStarted = true
const currentTarget = `${window.location.pathname}${window.location.search}${window.location.hash}`
const loginUrl = new URL('/login', window.location.origin)
if (currentTarget !== '/') loginUrl.searchParams.set('redirect', currentTarget)
window.location.assign(`${loginUrl.pathname}${loginUrl.search}`)
}
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
const auth = useAuthStore()
if (!auth.initialized) await auth.initialize()
@@ -28,7 +40,7 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
const refreshed = await auth.refresh()
if (!refreshed) {
if (window.location.pathname !== '/login') window.location.assign('/login')
redirectToLogin()
return response
}
+8 -4
View File
@@ -28,13 +28,17 @@ const STANDARD_METRICS = new Set<BrowserMetricName>(['CLS', 'FCP', 'INP', 'LCP',
let activeRouter: Router | null = null
let started = false
export function isBrowserTelemetryEnabled(): boolean {
return String(import.meta.env.VITE_BROWSER_TELEMETRY_ENABLED ?? 'true').toLowerCase() !== 'false'
}
function safeRouteName(): string {
const name = activeRouter?.currentRoute.value.name
return typeof name === 'string' && /^[A-Za-z0-9 _-]{1,80}$/.test(name) ? name : 'unknown'
}
async function report(envelope: BrowserMetricEnvelope): Promise<void> {
if (safeRouteName() === 'Login') return
if (!isBrowserTelemetryEnabled() || safeRouteName() === 'Login') return
try {
await apiFetch('/api/v1/telemetry/browser', {
method: 'POST',
@@ -53,7 +57,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null {
value: metric.value,
rating: metric.rating,
routeName: safeRouteName(),
buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
buildVersion: String(import.meta.env.VITE_BUILD_VERSION || import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
navigationType: String(metric.navigationType || 'navigate').slice(0, 40),
liveMode: 'unknown',
correlationId: null,
@@ -62,7 +66,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null {
export function startBrowserTelemetry(router: Router): void {
activeRouter = router
if (started || typeof window === 'undefined') return
if (!isBrowserTelemetryEnabled() || started || typeof window === 'undefined') return
started = true
const callback = (metric: Metric) => {
@@ -91,7 +95,7 @@ export async function reportCustomMetric(
value,
rating: 'custom',
routeName: safeRouteName(),
buildVersion: String(import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
buildVersion: String(import.meta.env.VITE_BUILD_VERSION || import.meta.env.VITE_BUILD_SHA || 'development').slice(0, 80),
navigationType: 'spa',
liveMode: options.liveMode ?? 'unknown',
correlationId: options.correlationId?.slice(0, 128) ?? null,
+9 -2
View File
@@ -108,16 +108,23 @@ export const useAuthStore = defineStore('auth', {
const body = await response.json() as Record<string, unknown>
if (typeof body.remaining === 'number') remaining = body.remaining
if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds
const problemMessage = typeof body.detail === 'string'
? body.detail
: typeof body.message === 'string'
? body.message
: typeof body.title === 'string'
? body.title
: null
if (response.status === 429) {
this.remainingAttempts = 0
this.retryAfterSeconds = retryAfter
throw new LoginError(body.message as string || 'Too many attempts.', 0, retryAfter)
throw new LoginError(problemMessage || 'Too many attempts.', 0, retryAfter)
} else if (response.status === 401) {
this.remainingAttempts = remaining
this.retryAfterSeconds = retryAfter
throw new LoginError(
body.message as string || 'Invalid email or password.',
problemMessage || 'Invalid email or password.',
remaining ?? 4,
retryAfter,
)
+22 -23
View File
@@ -12,11 +12,12 @@ import {
X,
} from '@lucide/vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import type { EntityRefDto } from '../api/contracts'
import { useActivity } from '../api/activity'
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
import EntityLink from '../components/mission-control/EntityLink.vue'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
interface UnifiedActivity {
id: string
@@ -37,6 +38,7 @@ interface UnifiedActivity {
const overviewQuery = useOpenClawOverviewQuery()
const nexusActivity = useActivity()
const route = useRoute()
const router = useRouter()
const query = ref('')
const sourceFilter = ref<'all' | 'OpenClaw' | 'Nexus'>('all')
const severityFilter = ref('all')
@@ -220,19 +222,15 @@ onUnmounted(() => {
</article>
</section>
<section
<AsyncStatePanel
v-if="runtimeCollection && runtimeCollection.state !== 'ready'"
class="runtime-state nexus-state"
:class="{ 'nexus-state--error': runtimeCollection.state === 'disconnected' || runtimeCollection.state === 'error' }"
>
<ServerCog :size="18" aria-hidden="true" />
<div>
<h2>OpenClaw audit feed: {{ runtimeCollection.state }}</h2>
<p>{{ runtimeCollection.message || 'Runtime activity is not currently available.' }}</p>
<p v-if="runtimeCollection.recovery">{{ runtimeCollection.recovery }}</p>
</div>
<RouterLink class="nexus-button" to="/settings">Diagnostics</RouterLink>
</section>
:state="['disconnected', 'error'].includes(runtimeCollection.state) ? 'offline' : 'partial'"
:title="`OpenClaw-Auditfeed: ${runtimeCollection.state}`"
:message="runtimeCollection.recovery || runtimeCollection.message || 'Runtime-Aktivität ist derzeit nicht vollständig verfügbar.'"
action-label="Diagnose öffnen"
compact
@action="router.push('/settings')"
/>
<section class="activity-toolbar nexus-panel" aria-label="Activity filters">
<label class="activity-search">
@@ -258,10 +256,11 @@ onUnmounted(() => {
</label>
</section>
<div v-if="overviewQuery.isPending.value && !overviewQuery.data.value && nexusActivity.isLoading.value" class="nexus-state nexus-state--loading">
<Loader2 :size="18" class="spin" aria-hidden="true" />
<p>Loading activity sources</p>
</div>
<AsyncStatePanel
v-if="overviewQuery.isPending.value && !overviewQuery.data.value && nexusActivity.isLoading.value"
state="loading"
title="Aktivitätsquellen werden geladen"
/>
<section v-else-if="filteredEvents.length" class="timeline" aria-label="Activity timeline">
<button
v-for="event in filteredEvents"
@@ -291,12 +290,12 @@ onUnmounted(() => {
</span>
</button>
</section>
<div v-else class="nexus-state nexus-state--empty">
<Activity :size="18" aria-hidden="true" />
<h2>No matching activity</h2>
<p v-if="events.length">Adjust the search or source filters.</p>
<p v-else>No Nexus or OpenClaw events have been returned yet.</p>
</div>
<AsyncStatePanel
v-else
state="empty"
title="Keine passende Aktivität"
:message="events.length ? 'Passe Suche oder Quellenfilter an.' : 'Nexus und OpenClaw haben noch keine Ereignisse geliefert.'"
/>
<Teleport to="body">
<div v-if="selectedEvent" class="activity-dialog-overlay" @click.self="closeDetails">
+14 -9
View File
@@ -17,6 +17,7 @@ import {
type CreateAgentProposalRequest,
} from '../api/agentProposals'
import { useAuthStore } from '../stores/auth'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const router = useRouter()
const auth = useAuthStore()
@@ -137,15 +138,19 @@ async function submitProposal() {
<span>Agent-Vorschläge enthalten OpenClaw-Konfiguration und sind deshalb ausschließlich für Owner sichtbar.</span>
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
</div>
<div v-else-if="optionsQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
OpenClaw-Optionen werden geprüft
</div>
<div v-else-if="optionsQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
<strong>Optionen nicht verfügbar</strong>
<span>{{ optionsQuery.error.value?.message || 'Die Create-Options konnten nicht geladen werden.' }}</span>
<button type="button" class="nexus-button" @click="optionsQuery.refetch()">Erneut prüfen</button>
</div>
<AsyncStatePanel
v-else-if="optionsQuery.isPending.value"
state="loading"
title="OpenClaw-Optionen werden geprüft"
/>
<AsyncStatePanel
v-else-if="optionsQuery.isError.value"
state="error"
title="Agent-Optionen nicht verfügbar"
:problem="optionsQuery.error.value"
action-label="Erneut prüfen"
@action="optionsQuery.refetch()"
/>
<template v-else-if="options">
<section
+143 -63
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
import { ArrowLeft, Bot, Activity, RefreshCw } from '@lucide/vue'
import { apiFetch } from '../services/api'
import {
applyAgentFileWrite,
@@ -22,6 +22,7 @@ import StandingOrdersEditor from '../components/config/StandingOrdersEditor.vue'
import { subscribeDomainEventState } from '../services/domainEvents'
import { createMutationRequestContext } from '../services/mutationContext'
import { useAuthStore } from '../stores/auth'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const route = useRoute()
const router = useRouter()
@@ -74,15 +75,6 @@ const configFiles = computed(() => filesQuery.data.value?.files ?? [])
const loading = computed(() => agentQuery.isPending.value)
const activityLoading = computed(() => activityQuery.isFetching.value)
const summaryLoading = computed(() => summaryQuery.isFetching.value)
const error = computed(() =>
agentQuery.error.value instanceof Error ? agentQuery.error.value.message : '',
)
const activityError = computed(() =>
activityQuery.error.value instanceof Error ? activityQuery.error.value.message : '',
)
const summaryError = computed(() =>
summaryQuery.error.value instanceof Error ? summaryQuery.error.value.message : '',
)
const currentFile = computed(() => {
if (!configFiles.value.length) return null
@@ -116,10 +108,7 @@ const configsError = computed(() => {
return cause instanceof Error ? cause.message : ''
})
const initLoading = computed(() =>
loading.value
|| activityQuery.isPending.value
|| summaryQuery.isPending.value
|| (canConfigure.value && filesQuery.isPending.value),
loading.value,
)
const fallbackName = computed(() => {
@@ -185,6 +174,23 @@ function scheduleActivityReload() {
}, 250)
}
function retryAgent() {
void agentQuery.refetch()
}
function retrySummary() {
void summaryQuery.refetch()
}
function retryActivity() {
void activityQuery.refetch()
}
function retryConfigs() {
void filesQuery.refetch()
if (activeTabFileName.value) void fileQuery.refetch()
}
function formatSummaryTimestamp(value?: string | null): string {
if (!value) return 'No timestamp'
const d = new Date(value)
@@ -316,12 +322,14 @@ async function saveFile() {
if (!response.ok) {
const err = await response.json().catch(() => ({}))
const problem = err as {
detail?: string
error?: string
message?: string
errors?: Record<string, string[]>
currentHash?: string
}
const detail = problem.message
const detail = problem.detail
|| problem.message
|| problem.error
|| Object.values(problem.errors ?? {}).flat().join(' ')
|| 'Failed to save file'
@@ -401,12 +409,30 @@ onUnmounted(() => {
Zurück zu Agents
</button>
<div v-if="initLoading" class="status-message">
<Loader2 :size="20" class="spin" />
Loading agent data...
</div>
<AsyncStatePanel
v-if="initLoading && !agent"
state="loading"
title="Agent wird geladen"
/>
<AsyncStatePanel
v-else-if="agentQuery.error.value && !agent"
state="error"
title="Agent nicht verfügbar"
:problem="agentQuery.error.value"
@action="retryAgent"
/>
<template v-else>
<AsyncStatePanel
v-if="agentQuery.error.value && agent"
state="stale"
title="Agentdaten möglicherweise veraltet"
:problem="agentQuery.error.value"
action-label="Aktualisieren"
compact
@action="retryAgent"
/>
<!-- Agent header -->
<div class="agent-header">
<div class="agent-avatar" :class="agentId">
@@ -426,12 +452,6 @@ onUnmounted(() => {
{{ formatLastSeen(agent.lastSeen) }}
</span>
</div>
<div v-if="error && !agent" class="agent-status-row">
<span class="status-label muted">
<AlertCircle :size="11" />
{{ error }}
</span>
</div>
</div>
</div>
@@ -455,15 +475,21 @@ onUnmounted(() => {
</button>
</header>
<div v-if="summaryLoading && !summary" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading summaries...
</div>
<AsyncStatePanel
v-if="summaryLoading && !summary"
state="loading"
title="Zusammenfassungen werden geladen"
compact
/>
<div v-else-if="summaryError && !summary" class="status-message compact error">
<AlertCircle :size="16" />
{{ summaryError }}
</div>
<AsyncStatePanel
v-else-if="summaryQuery.error.value && !summary"
state="error"
title="Zusammenfassungen nicht verfügbar"
:problem="summaryQuery.error.value"
compact
@action="retrySummary"
/>
<div v-else-if="summary" class="summary-row">
<div class="summary-card">
@@ -478,30 +504,61 @@ onUnmounted(() => {
</div>
</div>
<div v-else class="status-message compact">
No summary available.
</div>
<AsyncStatePanel
v-else
state="empty"
title="Keine Zusammenfassung"
message="OpenClaw hat für diesen Agenten noch keine bestätigte Zusammenfassung geliefert."
compact
/>
<div v-if="summaryError && summary" class="status-message compact error summary-inline-error">
<AlertCircle :size="14" />
{{ summaryError }}
</div>
<AsyncStatePanel
v-if="summaryQuery.error.value && summary"
state="stale"
title="Zusammenfassung möglicherweise veraltet"
:problem="summaryQuery.error.value"
action-label="Aktualisieren"
compact
@action="retrySummary"
/>
<div v-if="liveUnavailable" class="status-message compact">
Live stream reconnecting
</div>
<AsyncStatePanel
v-if="liveUnavailable"
state="stale"
title="Live-Stream wird neu verbunden"
message="Die letzte bestätigte Aktivität bleibt sichtbar."
action-label="Manuell aktualisieren"
compact
@action="refreshActivity"
/>
<div v-if="activityLoading && !activityItems.length" class="status-message compact">
<Loader2 :size="16" class="spin" />
Loading activity...
</div>
<AsyncStatePanel
v-if="activityLoading && !activityItems.length"
state="loading"
title="Aktivität wird geladen"
compact
/>
<div v-else-if="activityError" class="status-message compact error">
<AlertCircle :size="16" />
{{ activityError }}
</div>
<AsyncStatePanel
v-else-if="activityQuery.error.value && !activityItems.length"
state="error"
title="Aktivität nicht verfügbar"
:problem="activityQuery.error.value"
compact
@action="retryActivity"
/>
<div v-else-if="activityItems.length" class="thinking-list">
<template v-else-if="activityItems.length">
<AsyncStatePanel
v-if="activityQuery.error.value"
state="stale"
title="Aktivitätsliste möglicherweise veraltet"
:problem="activityQuery.error.value"
action-label="Aktualisieren"
compact
@action="retryActivity"
/>
<div class="thinking-list">
<article
v-for="item in activityItems"
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
@@ -514,25 +571,48 @@ onUnmounted(() => {
<p>{{ item.message }}</p>
</article>
</div>
</template>
<div v-else class="status-message compact">
No recent activity.
</div>
<AsyncStatePanel
v-else
state="empty"
title="Keine aktuelle Aktivität"
message="Für diesen Agenten liegen noch keine bestätigten Events vor."
compact
/>
</section>
<!-- Config section -->
<div class="config-section">
<div v-if="configsLoading" class="status-message">
<Loader2 :size="16" class="spin" />
Loading config files...
</div>
<AsyncStatePanel
v-if="configsLoading && !configFiles.length"
state="loading"
title="Agent-Dateien werden geladen"
compact
/>
<div v-else-if="configsError" class="status-message error">
<AlertCircle :size="16" />
{{ configsError }}
</div>
<AsyncStatePanel
v-else-if="configsError && !configFiles.length"
state="error"
title="Agent-Dateien nicht verfügbar"
:message="configsError"
:problem="fileQuery.error.value ?? filesQuery.error.value"
:action-label="canConfigure ? 'Aktualisieren' : ''"
compact
@action="retryConfigs"
/>
<template v-else>
<AsyncStatePanel
v-if="configsError"
state="stale"
title="Agent-Dateiliste möglicherweise veraltet"
:message="configsError"
:problem="fileQuery.error.value ?? filesQuery.error.value"
action-label="Aktualisieren"
compact
@action="retryConfigs"
/>
<ConfigTabs
:tabs="configFiles.map(file => file.name)"
:active-tab="activeTab"
+13 -9
View File
@@ -30,6 +30,7 @@ import {
type OperationResultDto,
} from '../api/contracts'
import OperationResultCard from '../components/mission-control/OperationResultCard.vue'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
type ActionMode = 'approve' | 'reject' | 'retry'
@@ -192,15 +193,18 @@ async function confirmAction() {
<span>Agent-Vorschläge und Provisionierungsaktionen sind ausschließlich für Owner sichtbar.</span>
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
</div>
<div v-else-if="proposalQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
Vorschlag wird geladen
</div>
<div v-else-if="proposalQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
<strong>Vorschlag nicht verfügbar</strong>
<span>{{ proposalQuery.error.value?.message || 'Der Proposal-Vertrag konnte nicht gelesen werden.' }}</span>
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
</div>
<AsyncStatePanel
v-else-if="proposalQuery.isPending.value"
state="loading"
title="Agent-Vorschlag wird geladen"
/>
<AsyncStatePanel
v-else-if="proposalQuery.isError.value"
state="error"
title="Agent-Vorschlag nicht verfügbar"
:problem="proposalQuery.error.value"
@action="proposalQuery.refetch()"
/>
<template v-else-if="proposal">
<section class="status-hero nexus-panel" :class="`status-hero--${statusMeta.tone}`">
+21 -6
View File
@@ -27,6 +27,7 @@ import {
} from '../api/openclawRuntime'
import { useAuthStore } from '../stores/auth'
import type { OpenClawAgent } from '../types/openclaw'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const router = useRouter()
const auth = useAuthStore()
@@ -254,8 +255,20 @@ function proposalStatus(proposal: AgentProposalDto) {
</div>
</div>
<div v-if="loading" class="load-error nexus-state nexus-state--loading" role="status">Lade Gateway-Status...</div>
<div v-else-if="error" class="load-error nexus-state nexus-state--error" role="alert">{{ error }}</div>
<AsyncStatePanel
v-if="loading"
state="loading"
title="Agenten werden geladen"
compact
/>
<AsyncStatePanel
v-else-if="error"
state="error"
title="Agenten konnten nicht geladen werden"
:problem="agentsQuery.error.value"
compact
@action="agentsQuery.refetch()"
/>
<div v-if="gatewayWarning" class="gateway-warning nexus-state nexus-status--warning" role="status">
{{ gatewayWarning }}
</div>
@@ -368,10 +381,12 @@ function proposalStatus(proposal: AgentProposalDto) {
</div>
</article>
</div>
<div v-else-if="!loading" class="empty-state nexus-state nexus-state--empty">
<h3>Keine Agenten sichtbar</h3>
<p>Mission Control hat aktuell keine Agenten aus dem Backend erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration.</p>
</div>
<AsyncStatePanel
v-else-if="!loading"
state="empty"
title="Keine Agenten sichtbar"
message="Mission Control hat aktuell keine Agenten erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration."
/>
</div>
</template>
+21 -21
View File
@@ -36,6 +36,7 @@ import {
useOpenClawCronRuns,
type CreateOpenClawCronJobRequest,
} from '../api/openClawCron'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
import { useAuthStore } from '../stores/auth'
import type {
@@ -870,28 +871,27 @@ onUnmounted(() => {
</button>
</section>
<div v-if="cronJobsLoading && !collection" class="nexus-state nexus-state--loading" role="status">
<Loader2 :size="18" class="spin" aria-hidden="true" />
<p>Loading OpenClaw schedules</p>
</div>
<div v-else-if="cronJobsError && !collection" class="nexus-state nexus-state--error" role="alert">
<AlertCircle :size="18" aria-hidden="true" />
<h2>Scheduler unavailable</h2>
<p>{{ cronJobsError }}</p>
<RouterLink class="nexus-button" to="/settings">OpenClaw diagnostics</RouterLink>
</div>
<div
<AsyncStatePanel
v-if="cronJobsLoading && !collection"
state="loading"
title="OpenClaw-Zeitpläne werden geladen"
/>
<AsyncStatePanel
v-else-if="cronJobsError && !collection"
state="error"
title="Scheduler nicht verfügbar"
:problem="jobsQuery.error.value"
action-label="OpenClaw-Diagnose öffnen"
@action="router.push('/settings')"
/>
<AsyncStatePanel
v-else-if="collection && collection.state !== 'ready'"
class="nexus-state"
:class="{ 'nexus-state--error': ['disconnected', 'error', 'failed'].includes(collection.state) }"
role="status"
>
<AlertCircle :size="18" aria-hidden="true" />
<h2>Scheduler {{ collection.state }}</h2>
<p>{{ collection.message || 'OpenClaw did not return its cron catalog.' }}</p>
<p v-if="collection.recovery">{{ collection.recovery }}</p>
<RouterLink class="nexus-button" to="/settings">Inspect connection</RouterLink>
</div>
:state="['disconnected', 'error', 'failed'].includes(collection.state) ? 'offline' : 'partial'"
:title="`Scheduler: ${collection.state}`"
:message="collection.recovery || collection.message || 'OpenClaw hat keinen vollständigen Cron-Katalog geliefert.'"
action-label="Verbindung prüfen"
@action="router.push('/settings')"
/>
<template v-else>
<section class="calendar-layout">
+23 -7
View File
@@ -27,6 +27,7 @@ import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
import { useFlowBoardState } from '../../composables/useFlowBoardState'
import AsyncStatePanel from '../../components/mission-control/AsyncStatePanel.vue'
/* ── Stores ──────────────────────────────────────── */
const agentStore = useAgentStore()
@@ -141,12 +142,6 @@ const taskItems = computed(() => activeCards.value.map(mapTask))
const blockedTasks = computed(() =>
activeCards.value.filter(task => task.state.toLowerCase() === 'blocked'),
)
const taskError = computed(() =>
taskBoard.query.error.value instanceof Error
? taskBoard.query.error.value.message
: null,
)
function handleBlockerClick() {
const blockedTask = blockedTasks.value[0]
if (!blockedTask) return
@@ -181,7 +176,22 @@ function blockerCount() {
@blocker-click="handleBlockerClick"
/>
<AsyncStatePanel
v-if="overviewQuery.isPending.value && !agentNodes.length"
class="orchestration-state"
state="loading"
title="Live-Orchestrierung wird geladen"
/>
<AsyncStatePanel
v-else-if="overviewQuery.error.value && !agentNodes.length"
class="orchestration-state"
state="offline"
title="Live-Orchestrierung nicht erreichbar"
:problem="overviewQuery.error.value"
@action="overviewQuery.refetch()"
/>
<FlowCanvas
v-else
:agents="agentNodes"
:positions="agentPositions"
:entering-ids="enteringIds"
@@ -193,7 +203,8 @@ function blockerCount() {
<TaskStrip
:tasks="taskItems"
:loading="taskBoard.query.isLoading.value"
:error="taskError"
:error="taskBoard.query.error.value"
@retry="taskBoard.query.refetch()"
/>
</div>
</div>
@@ -242,6 +253,11 @@ function blockerCount() {
overflow: hidden;
}
.orchestration-state {
flex: 1;
align-content: center;
}
@media (max-width: 767px) {
.board-body {
padding: 8px;
+63 -17
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { BookOpen, Search, ArrowLeft, Clock, FileText, Filter, Loader2 } from '@lucide/vue'
import { BookOpen, Search, ArrowLeft, Clock, FileText, Filter } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import { useDocQuery, useDocsQuery } from '../api/knowledge'
import { renderMarkdown } from '../utils/markdown'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
// State
const docsQuery = useDocsQuery()
@@ -22,10 +23,8 @@ const selectedDocInfo = computed(() =>
docs.value.find(doc => doc.path === selectedDocPath.value) ?? null,
)
const contentLoading = computed(() => docQuery.isFetching.value)
const error = computed(() => {
const cause = docQuery.error.value ?? docsQuery.error.value
return cause instanceof Error ? cause.message : ''
})
const listProblem = computed(() => docsQuery.error.value)
const contentProblem = computed(() => docQuery.error.value)
const categories = ['phases', 'skills', 'workspace', 'nexus', 'nexus-phases']
@@ -50,6 +49,14 @@ function goBack() {
selectedDocPath.value = ''
}
function retryList() {
void docsQuery.refetch()
}
function retryContent() {
void docQuery.refetch()
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
@@ -98,12 +105,30 @@ function formatSize(bytes: number | string): string {
<div class="memory-layout">
<!-- Left column: document list -->
<aside class="memory-sidebar">
<div v-if="loading" class="memory-status">
<Loader2 :size="16" class="spin" />
Loading documents...
</div>
<div v-else-if="error" class="memory-status error">{{ error }}</div>
<AsyncStatePanel
v-if="loading && !docs.length"
state="loading"
title="Dokumente werden geladen"
compact
/>
<AsyncStatePanel
v-else-if="listProblem && !docs.length"
state="error"
title="Dokumente nicht verfügbar"
:problem="listProblem"
compact
@action="retryList"
/>
<template v-else-if="filteredDocs.length">
<AsyncStatePanel
v-if="listProblem"
state="stale"
title="Dokumentliste möglicherweise veraltet"
:problem="listProblem"
action-label="Aktualisieren"
compact
@action="retryList"
/>
<div class="memory-list-header">{{ filteredDocs.length }} documents</div>
<button
v-for="doc in filteredDocs"
@@ -129,18 +154,39 @@ function formatSize(bytes: number | string): string {
</div>
</button>
</template>
<div v-else class="memory-status">No documents match your filters</div>
<AsyncStatePanel
v-else
state="empty"
title="Keine passenden Dokumente"
message="Passe Suche oder Kategorie an."
compact
/>
</aside>
<!-- Right column: content -->
<main class="memory-content">
<template v-if="contentLoading">
<div class="memory-status">
<Loader2 :size="20" class="spin" />
Loading content...
</div>
</template>
<AsyncStatePanel
v-if="contentLoading && !selectedDoc"
state="loading"
title="Dokument wird geladen"
/>
<AsyncStatePanel
v-else-if="contentProblem && !selectedDoc"
state="error"
title="Dokument nicht verfügbar"
:problem="contentProblem"
@action="retryContent"
/>
<template v-else-if="selectedDoc">
<AsyncStatePanel
v-if="contentProblem"
state="stale"
title="Angezeigtes Dokument ist möglicherweise veraltet"
:problem="contentProblem"
action-label="Aktualisieren"
compact
@action="retryContent"
/>
<header class="memory-content-header">
<button type="button" class="memory-back-btn" @click="goBack">
<ArrowLeft :size="14" />
+78 -21
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { FileText, AlertTriangle, AlertCircle, Info, Activity, ArrowLeft, Clock, Loader2, ServerCog } from '@lucide/vue'
import { AlertTriangle, AlertCircle, Info, ArrowLeft, Clock, ServerCog } from '@lucide/vue'
import { useIncidentQuery, useIncidentsQuery } from '../api/incidents'
import { renderMarkdown } from '../utils/markdown'
import { RouterLink } from 'vue-router'
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const overviewQuery = useOpenClawOverviewQuery()
const incidentsQuery = useIncidentsQuery()
@@ -21,10 +22,8 @@ const selectedIncidentSummary = computed(() =>
incidents.value.find(incident => incident.name === selectedIncidentName.value) ?? null,
)
const contentLoading = computed(() => incidentQuery.isFetching.value)
const error = computed(() => {
const cause = incidentQuery.error.value ?? incidentsQuery.error.value
return cause instanceof Error ? cause.message : ''
})
const listProblem = computed(() => incidentsQuery.error.value)
const contentProblem = computed(() => incidentQuery.error.value)
// Sorted incidents (newest first by date)
const sortedIncidents = computed(() => {
@@ -68,6 +67,18 @@ function goBack() {
selectedIncidentName.value = ''
}
function retryList() {
void incidentsQuery.refetch()
}
function retryContent() {
void incidentQuery.refetch()
}
function retryRuntime() {
void overviewQuery.refetch()
}
function formatDate(dateStr: string): string {
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
@@ -118,19 +129,44 @@ function formatSize(bytes: number | string): string {
</div>
</section>
<AsyncStatePanel
v-else-if="overviewQuery.error.value"
state="partial"
title="Live-Incidents nicht vollständig"
message="Post-Mortems bleiben verfügbar; aktuelle OpenClaw-Fehler konnten nicht synchronisiert werden."
:problem="overviewQuery.error.value"
action-label="Runtime aktualisieren"
compact
@action="retryRuntime"
/>
<div class="incident-layout">
<!-- Left column: incident list -->
<aside class="incident-sidebar">
<template v-if="loading">
<div class="incident-status">
<Loader2 :size="16" class="spin" />
Loading incidents...
</div>
</template>
<template v-else-if="error">
<div class="incident-status error">{{ error }}</div>
</template>
<AsyncStatePanel
v-if="loading && !incidents.length"
state="loading"
title="Incidents werden geladen"
compact
/>
<AsyncStatePanel
v-else-if="listProblem && !incidents.length"
state="error"
title="Incident-Berichte nicht verfügbar"
:problem="listProblem"
compact
@action="retryList"
/>
<template v-else-if="sortedIncidents.length">
<AsyncStatePanel
v-if="listProblem"
state="stale"
title="Incident-Liste möglicherweise veraltet"
:problem="listProblem"
action-label="Aktualisieren"
compact
@action="retryList"
/>
<div class="incident-list-header">{{ sortedIncidents.length }} reports</div>
<button
v-for="inc in sortedIncidents"
@@ -154,18 +190,39 @@ function formatSize(bytes: number | string): string {
</div>
</button>
</template>
<div v-else class="incident-status">No incidents recorded</div>
<AsyncStatePanel
v-else
state="empty"
title="Keine Incidents erfasst"
message="Es liegen weder auswählbare Post-Mortems noch bestätigte Incident-Berichte vor."
compact
/>
</aside>
<!-- Right column: detail view -->
<main class="incident-content">
<template v-if="contentLoading">
<div class="incident-status">
<Loader2 :size="20" class="spin" />
Loading incident...
</div>
</template>
<AsyncStatePanel
v-if="contentLoading && !selectedIncident"
state="loading"
title="Incident wird geladen"
/>
<AsyncStatePanel
v-else-if="contentProblem && !selectedIncident"
state="error"
title="Incident nicht verfügbar"
:problem="contentProblem"
@action="retryContent"
/>
<template v-else-if="selectedIncident">
<AsyncStatePanel
v-if="contentProblem"
state="stale"
title="Angezeigter Incident ist möglicherweise veraltet"
:problem="contentProblem"
action-label="Aktualisieren"
compact
@action="retryContent"
/>
<header class="incident-content-header">
<button type="button" class="incident-back-btn" @click="goBack">
<ArrowLeft :size="14" />
+98 -22
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { FileText, Search, ArrowLeft, Clock, Database, Loader2 } from '@lucide/vue'
import { FileText, Search, ArrowLeft, Clock, Database } from '@lucide/vue'
import { RouterLink } from 'vue-router'
import {
useMemoryFileQuery,
@@ -8,6 +8,7 @@ import {
useMemorySearchQuery,
} from '../api/knowledge'
import { renderMarkdown } from '../utils/markdown'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
// State
const memoriesQuery = useMemoryFilesQuery()
@@ -27,10 +28,9 @@ const selectedMemory = computed(() =>
selectedMemoryName.value ? memoryQuery.data.value ?? null : null,
)
const contentLoading = computed(() => memoryQuery.isFetching.value)
const error = computed(() => {
const cause = memoryQuery.error.value ?? memoriesQuery.error.value
return cause instanceof Error ? cause.message : ''
})
const listProblem = computed(() => memoriesQuery.error.value)
const contentProblem = computed(() => memoryQuery.error.value)
const searchProblem = computed(() => searchResultsQuery.error.value)
// Sorted memories (newest first)
const sortedMemories = computed(() => {
@@ -63,6 +63,18 @@ function goBack() {
selectedMemoryName.value = ''
}
function retryList() {
void memoriesQuery.refetch()
}
function retrySearch() {
void searchResultsQuery.refetch()
}
function retryContent() {
void memoryQuery.refetch()
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: 'short', day: 'numeric',
@@ -109,11 +121,30 @@ onUnmounted(() => {
<aside class="memory-sidebar">
<!-- Search results -->
<template v-if="searchQuery.trim().length >= 2">
<div v-if="searchLoading" class="memory-status">
<Loader2 :size="16" class="spin" />
Searching...
</div>
<AsyncStatePanel
v-if="searchLoading && !searchResults.length"
state="loading"
title="Memory wird durchsucht"
compact
/>
<AsyncStatePanel
v-else-if="searchProblem && !searchResults.length"
state="error"
title="Suche fehlgeschlagen"
:problem="searchProblem"
compact
@action="retrySearch"
/>
<template v-else-if="searchResults.length">
<AsyncStatePanel
v-if="searchProblem"
state="stale"
title="Suchergebnisse möglicherweise veraltet"
:problem="searchProblem"
action-label="Erneut suchen"
compact
@action="retrySearch"
/>
<div class="memory-list-header">Search results ({{ searchResults.length }})</div>
<button
v-for="result in searchResults"
@@ -130,17 +161,41 @@ onUnmounted(() => {
</div>
</button>
</template>
<div v-else class="memory-status">No results found</div>
<AsyncStatePanel
v-else
state="empty"
title="Keine Treffer"
message="Passe den Suchbegriff an oder öffne die vollständige Dateiliste."
compact
/>
</template>
<!-- File list (default) -->
<template v-else>
<div v-if="loading" class="memory-status">
<Loader2 :size="16" class="spin" />
Loading memory files...
</div>
<div v-else-if="error" class="memory-status error">{{ error }}</div>
<AsyncStatePanel
v-if="loading && !memories.length"
state="loading"
title="Memory-Dateien werden geladen"
compact
/>
<AsyncStatePanel
v-else-if="listProblem && !memories.length"
state="error"
title="Memory-Dateien nicht verfügbar"
:problem="listProblem"
compact
@action="retryList"
/>
<template v-else-if="sortedMemories.length">
<AsyncStatePanel
v-if="listProblem"
state="stale"
title="Dateiliste möglicherweise veraltet"
:problem="listProblem"
action-label="Aktualisieren"
compact
@action="retryList"
/>
<div class="memory-list-header">{{ sortedMemories.length }} files</div>
<button
v-for="mem in sortedMemories"
@@ -161,19 +216,40 @@ onUnmounted(() => {
</div>
</button>
</template>
<div v-else class="memory-status">No memory files available</div>
<AsyncStatePanel
v-else
state="empty"
title="Keine Memory-Dateien"
message="OpenClaw meldet für die verbundenen Agent-Workspaces noch keine Memory-Dateien."
compact
/>
</template>
</aside>
<!-- Right column: content -->
<main class="memory-content">
<template v-if="contentLoading">
<div class="memory-status">
<Loader2 :size="20" class="spin" />
Loading content...
</div>
</template>
<AsyncStatePanel
v-if="contentLoading && !selectedMemory"
state="loading"
title="Memory-Inhalt wird geladen"
/>
<AsyncStatePanel
v-else-if="contentProblem && !selectedMemory"
state="error"
title="Memory-Inhalt nicht verfügbar"
:problem="contentProblem"
@action="retryContent"
/>
<template v-else-if="selectedMemory">
<AsyncStatePanel
v-if="contentProblem"
state="stale"
title="Angezeigter Inhalt ist möglicherweise veraltet"
:problem="contentProblem"
action-label="Aktualisieren"
compact
@action="retryContent"
/>
<header class="memory-content-header">
<button type="button" class="memory-back-btn" @click="goBack">
<ArrowLeft :size="14" />
+36 -52
View File
@@ -14,13 +14,15 @@ import {
X,
} from '@lucide/vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { RouterLink, useRouter } from 'vue-router'
import {
refreshOpenClawModelAuthStatus,
useOpenClawModelAuthQuery,
type OpenClawModelAuthProviderDto,
} from '../api/openClawModels'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const router = useRouter()
const modelAuthQuery = useOpenClawModelAuthQuery()
const query = ref('')
const statusFilter = ref('all')
@@ -250,54 +252,38 @@ onUnmounted(() => {
</article>
</section>
<div
<AsyncStatePanel
v-if="modelAuthQuery.isPending.value && !collection"
class="nexus-state nexus-state--loading"
role="status"
>
<Loader2 :size="18" class="spin" aria-hidden="true" />
<p>Loading sanitized provider health from OpenClaw</p>
</div>
<div
state="loading"
title="Provider-Status wird geladen"
message="Nexus liest den bereinigten Authentifizierungsstatus aus OpenClaw."
/>
<AsyncStatePanel
v-else-if="modelAuthError && !collection"
class="nexus-state nexus-state--error"
role="alert"
>
<CircleAlert :size="18" aria-hidden="true" />
<h2>Provider health unavailable</h2>
<p>{{ modelAuthError }}</p>
<div class="state-actions">
<button type="button" class="nexus-button nexus-button--primary" @click="refresh">Try again</button>
<RouterLink class="nexus-button" to="/settings">OpenClaw diagnostics</RouterLink>
</div>
</div>
<div
state="error"
title="Provider-Status nicht verfügbar"
:problem="modelAuthQuery.error.value"
@action="refresh"
/>
<AsyncStatePanel
v-else-if="collection && collection.state !== 'ready'"
class="nexus-state"
:class="{
'nexus-state--error':
collection.state === 'error'
|| collection.state === 'disconnected'
|| collection.state === 'forbidden',
}"
role="status"
>
<ShieldAlert :size="18" aria-hidden="true" />
<h2>
{{ collection.state === 'unsupported'
? 'Provider health is not supported'
: 'Provider health is not ready' }}
</h2>
<p>{{ collection.message || 'OpenClaw did not return provider authentication health.' }}</p>
<p v-if="collection.recovery">{{ collection.recovery }}</p>
<RouterLink class="nexus-button" to="/settings">Inspect connection</RouterLink>
</div>
:state="['error', 'disconnected', 'forbidden'].includes(collection.state) ? 'offline' : 'partial'"
:title="collection.state === 'unsupported' ? 'Provider-Status nicht unterstützt' : `Provider-Status: ${collection.state}`"
:message="collection.recovery || collection.message || 'OpenClaw hat keinen verwendbaren Provider-Snapshot geliefert.'"
action-label="OpenClaw-Setup öffnen"
@action="router.push('/settings')"
/>
<template v-else-if="collection">
<div v-if="modelAuthError" class="refresh-warning nexus-state nexus-state--error" role="alert">
<CircleAlert :size="16" aria-hidden="true" />
<p>Refresh failed. The last successful snapshot remains visible. {{ modelAuthError }}</p>
</div>
<AsyncStatePanel
v-if="modelAuthError"
state="stale"
title="Letzter bestätigter Provider-Stand"
:problem="modelAuthQuery.error.value"
message="Der Refresh ist fehlgeschlagen; der letzte bestätigte Snapshot bleibt sichtbar."
compact
@action="refresh"
/>
<section class="model-toolbar nexus-panel" aria-label="Provider health filters">
<label class="model-search">
@@ -359,14 +345,12 @@ onUnmounted(() => {
<span v-if="provider.usage?.plan" class="model-plan">{{ provider.usage.plan }}</span>
</button>
</section>
<div v-else class="nexus-state nexus-state--empty">
<ShieldCheck :size="18" aria-hidden="true" />
<h2>{{ providers.length ? 'No matching providers' : 'No provider health reported' }}</h2>
<p v-if="providers.length">Adjust the provider search or status filter.</p>
<p v-else>
OpenClaw returned no configured authentication profiles or API-key sources.
</p>
</div>
<AsyncStatePanel
v-else
state="empty"
:title="providers.length ? 'Keine passenden Provider' : 'Kein Provider-Status gemeldet'"
:message="providers.length ? 'Passe Suche oder Statusfilter an.' : 'OpenClaw hat keine konfigurierten Auth-Profile gemeldet.'"
/>
</template>
<Teleport to="body">
+19 -12
View File
@@ -14,6 +14,7 @@ import {
ShieldCheck,
UserRound,
} from '@lucide/vue'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const router = useRouter()
const route = useRoute()
@@ -142,18 +143,24 @@ watch(
</div>
</section>
<div v-if="notificationQuery.isLoading.value && !sortedNotifications.length" class="empty-state nexus-state nexus-state--loading" role="status">
<Loader2 :size="24" class="spin" />
<p>Benachrichtigungen werden geladen</p>
</div>
<div v-else-if="notificationQuery.error.value && !sortedNotifications.length" class="empty-state nexus-state nexus-state--error" role="alert">
<BellOff :size="28" />
<p>{{ notificationQuery.error.value.message }}</p>
</div>
<div v-else-if="sortedNotifications.length === 0" class="empty-state nexus-state nexus-state--empty">
<BellOff :size="48" />
<p>Keine Benachrichtigungen</p>
</div>
<AsyncStatePanel
v-if="notificationQuery.isLoading.value && !sortedNotifications.length"
state="loading"
title="Benachrichtigungen werden geladen"
/>
<AsyncStatePanel
v-else-if="notificationQuery.error.value && !sortedNotifications.length"
state="error"
title="Benachrichtigungen nicht verfügbar"
:problem="notificationQuery.error.value"
@action="notificationQuery.refetch()"
/>
<AsyncStatePanel
v-else-if="sortedNotifications.length === 0"
state="empty"
title="Keine Benachrichtigungen"
message="Neue Aufgaben, Freigaben und Runtime-Ereignisse erscheinen hier."
/>
<div v-else class="notification-list">
<div
+14 -2
View File
@@ -18,6 +18,7 @@ import { useProject, useProjectTasks } from '../api/projects'
import { useOpenClawRuns } from '../api/openClawRuns'
import type { EntityRefDto } from '../api/contracts'
import EntityLink from '../components/mission-control/EntityLink.vue'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const route = useRoute()
const router = useRouter()
@@ -115,8 +116,19 @@ function getTaskStateIcon(state: string) {
</button>
</header>
<div v-if="loading" class="loading-state">Loading project...</div>
<div v-else-if="loadError" class="error-state">{{ loadError }}</div>
<AsyncStatePanel
v-if="loading && !project"
state="loading"
title="Projekt wird geladen"
/>
<AsyncStatePanel
v-else-if="loadError && !project"
state="error"
title="Projekt konnte nicht geladen werden"
:message="loadError"
action-label="Erneut versuchen"
@action="projectState.query.refetch()"
/>
<template v-else-if="project">
<div class="project-detail-card">
<div class="project-detail-top">
+19 -19
View File
@@ -3,6 +3,7 @@ import { Boxes, CalendarClock, CircleAlert, Loader2, Plus, RefreshCw } from '@lu
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useProjects } from '../api/projects'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const projects = useProjects()
const name = ref('')
@@ -94,25 +95,24 @@ async function submit() {
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
</form>
<section v-if="projects.query.isLoading.value" class="nexus-state nexus-state--loading" role="status">
<Loader2 :size="22" class="spin" aria-hidden="true" />
<p>Projekte werden geladen</p>
</section>
<section v-else-if="projects.query.isError.value" class="nexus-state nexus-state--error" role="alert">
<CircleAlert :size="22" aria-hidden="true" />
<div>
<h2>Projektliste nicht verfügbar</h2>
<p>{{ projects.query.error.value instanceof Error ? projects.query.error.value.message : 'Unbekannter Fehler' }}</p>
</div>
<button type="button" class="nexus-button" @click="projects.query.refetch()">Erneut versuchen</button>
</section>
<section v-else-if="!items.length" class="nexus-state nexus-state--empty">
<Boxes :size="28" aria-hidden="true" />
<div>
<h2>Noch keine Projekte</h2>
<p>Lege den ersten Mission-Scope über das Formular an.</p>
</div>
</section>
<AsyncStatePanel
v-if="projects.query.isLoading.value"
state="loading"
title="Projekte werden geladen"
/>
<AsyncStatePanel
v-else-if="projects.query.isError.value"
state="error"
title="Projektliste nicht verfügbar"
:problem="projects.query.error.value"
@action="projects.query.refetch()"
/>
<AsyncStatePanel
v-else-if="!items.length"
state="empty"
title="Noch keine Projekte"
message="Lege den ersten Mission-Scope über das Formular an."
/>
<section v-else class="project-grid" aria-label="Projekte">
<RouterLink
v-for="project in items"
+22 -20
View File
@@ -36,6 +36,7 @@ import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
import { useAuthStore } from '../stores/auth'
import { useMissionControlUiStore } from '../stores/missionControlUi'
import { useOpenClawStore } from '../stores/openclaw'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
import type {
OpenClawActivity,
OpenClawApproval,
@@ -423,28 +424,29 @@ onMounted(() => {
</div>
</header>
<div v-if="overviewQuery.isPending.value && !overview" class="nexus-state nexus-state--loading" role="status">
<Loader2 :size="20" class="spin" />
<div><h2>OpenClaw control plane wird geladen</h2><p>Gateway handshake and capability negotiation are in progress.</p></div>
</div>
<AsyncStatePanel
v-if="overviewQuery.isPending.value && !overview"
state="loading"
title="OpenClaw Control Plane wird geladen"
message="Gateway-Handshake und Capability-Aushandlung laufen."
/>
<div v-else-if="overviewError && !overview" class="nexus-state nexus-state--error" role="alert">
<AlertTriangle :size="20" />
<div><h2>Run Control ist nicht erreichbar</h2><p>{{ overviewError }}</p></div>
</div>
<AsyncStatePanel
v-else-if="overviewError && !overview"
state="error"
title="Run Control ist nicht erreichbar"
:problem="overviewQuery.error.value"
@action="overviewQuery.refetch()"
/>
<section v-else-if="connection && !connection.connected" class="disconnected-state nexus-state nexus-state--error">
<Network :size="24" />
<div>
<h2>OpenClaw Gateway ist {{ connection.state }}</h2>
<p>{{ connection.recovery || connection.message || 'Gateway configuration and credentials must be checked.' }}</p>
<code v-if="connection.pairingRequired">
Pairing request: {{ connection.pairingRequestId || 'not reported' }}
</code>
<code>{{ connection.endpoint }}</code>
</div>
<RouterLink class="nexus-button" to="/settings">Integration prüfen</RouterLink>
</section>
<AsyncStatePanel
v-else-if="connection && !connection.connected"
state="offline"
:title="`OpenClaw Gateway ist ${connection.state}`"
:message="connection.recovery || connection.message || 'Gateway-Konfiguration und Zugang müssen geprüft werden.'"
action-label="Integration prüfen"
@action="router.push('/settings')"
/>
<template v-else-if="overview">
<section class="run-strip" aria-label="OpenClaw control-plane status">
+15 -9
View File
@@ -25,6 +25,7 @@ import {
} from '../api/openClawRuns'
import { useAuthStore } from '../stores/auth'
import { useOpenClawStore } from '../stores/openclaw'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
type RunAction = 'stop' | 'retry' | 'resume'
@@ -202,16 +203,21 @@ async function executeAction() {
</div>
</header>
<div v-if="detailLoading && !run" class="nexus-state nexus-state--loading" role="status">
<Loader2 :size="20" class="spin" />
<div><h2>Loading durable run</h2><p>Nexus is reconciling local transitions with OpenClaw history.</p></div>
</div>
<AsyncStatePanel
v-if="detailLoading && !run"
state="loading"
title="Durable Run wird geladen"
message="Nexus gleicht lokale Transitionen mit der OpenClaw-Historie ab."
/>
<div v-else-if="detailError && !run" class="nexus-state nexus-state--error" role="alert">
<AlertTriangle :size="20" />
<div><h2>Run could not be loaded</h2><p>{{ detailError }}</p></div>
<RouterLink class="nexus-button" to="/runs">Return to Run Control</RouterLink>
</div>
<AsyncStatePanel
v-else-if="detailError && !run"
state="error"
title="Run konnte nicht geladen werden"
:message="detailError"
action-label="Zur Run Control"
@action="router.push('/runs')"
/>
<template v-else-if="run">
<section v-if="run.sequenceGapDetected" class="gap-warning" role="alert">
+13 -9
View File
@@ -22,6 +22,7 @@ import {
useOpenClawOverviewQuery,
} from '../api/openclawRuntime'
import { useSecurityStatusQuery } from '../api/security'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const statusQuery = useSecurityStatusQuery()
const status = computed(() => statusQuery.data.value ?? null)
@@ -165,15 +166,18 @@ async function refresh() {
</div>
</section>
<div v-if="loading && !status" class="nexus-state nexus-state--loading" role="status">
<Loader2 :size="18" class="spin" aria-hidden="true" />
<p>Loading Nexus security configuration</p>
</div>
<div v-else-if="error && !status" class="nexus-state nexus-state--error" role="alert">
<CircleAlert :size="18" aria-hidden="true" />
<h2>Nexus security status unavailable</h2>
<p>{{ error }}</p>
</div>
<AsyncStatePanel
v-if="loading && !status"
state="loading"
title="Security-Konfiguration wird geladen"
/>
<AsyncStatePanel
v-else-if="error && !status"
state="error"
title="Nexus Security-Status nicht verfügbar"
:problem="statusQuery.error.value"
@action="refresh"
/>
<section v-else-if="status" class="security-grid" aria-label="Nexus security controls">
<article class="security-card nexus-card">
+41 -17
View File
@@ -9,13 +9,15 @@
import { nextTick, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
Save, Lock, User, Shield, Plus, Mail, Trash2, Users,
Save, Lock, User, Shield, Plus, Mail, Trash2,
Eye, EyeOff, CheckCircle, X,
} from '@lucide/vue'
import { useAuthStore } from '../stores/auth'
import { apiFetch } from '../services/api'
import { throwApiProblem } from '../api/contracts'
import OpenClawConfigEditor from '../components/openclaw/OpenClawConfigEditor.vue'
import OpenClawSetupCenter from '../components/openclaw/OpenClawSetupCenter.vue'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
const auth = useAuthStore()
const route = useRoute()
@@ -119,7 +121,7 @@ interface AdminUser {
const users = ref<AdminUser[]>([])
const usersLoading = ref(false)
const usersError = ref('')
const usersProblem = ref<unknown>(null)
const showCreateUser = ref(false)
const createEmail = ref('')
const createPassword = ref('')
@@ -134,13 +136,13 @@ const canManageUsers = auth.user?.role === 'owner' || auth.user?.role === 'admin
async function loadUsers() {
if (!canManageUsers) return
usersLoading.value = true
usersError.value = ''
usersProblem.value = null
try {
const res = await apiFetch('/api/v1/admin/users')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.ok) await throwApiProblem(res, 'Benutzer konnten nicht geladen werden')
users.value = await res.json()
} catch (e) {
usersError.value = e instanceof Error ? e.message : 'Benutzer konnten nicht geladen werden'
usersProblem.value = e
} finally {
usersLoading.value = false
}
@@ -339,20 +341,33 @@ onMounted(() => {
</button>
</div>
<p v-if="usersError" class="msg error">{{ usersError }}</p>
<AsyncStatePanel
v-if="usersLoading && !users.length"
state="loading"
title="Benutzer werden geladen"
compact
/>
<div v-if="usersLoading" class="loading-pulse">
<div class="pulse-bar"></div>
<div class="pulse-bar"></div>
<div class="pulse-bar"></div>
</div>
<AsyncStatePanel
v-else-if="usersProblem && !users.length"
state="error"
title="Benutzer nicht verfügbar"
:problem="usersProblem"
compact
@action="loadUsers"
/>
<div v-else-if="users.length === 0" class="empty-state">
<Users :size="32" />
<p>Noch keine Benutzer. Lege den ersten an.</p>
</div>
<div v-else class="user-list">
<template v-else-if="users.length">
<AsyncStatePanel
v-if="usersProblem"
state="stale"
title="Benutzerliste möglicherweise veraltet"
:problem="usersProblem"
action-label="Aktualisieren"
compact
@action="loadUsers"
/>
<div class="user-list">
<div v-for="user in users" :key="user.id" class="user-row">
<div class="user-avatar">
{{ user.displayName.charAt(0).toUpperCase() }}
@@ -371,6 +386,15 @@ onMounted(() => {
</div>
</div>
</div>
</template>
<AsyncStatePanel
v-else
state="empty"
title="Noch keine Benutzer"
message="Lege den ersten zusätzlichen Nexus-Benutzer an."
compact
/>
<!-- Create User Modal -->
<Teleport to="body">
+25 -10
View File
@@ -34,6 +34,7 @@ import {
import { RouterLink, useRouter } from 'vue-router'
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
import { useTaskBoard } from '../api/taskBoard'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
import { useTaskActivity, useTaskChildren } from '../api/tasks'
import { subscribeDomainEventState } from '../services/domainEvents'
import type { SseConnectionState } from '../services/sseHub'
@@ -603,18 +604,32 @@ onUnmounted(() => {
</div>
</div>
<div v-if="taskBoard.query.isLoading.value" class="board-loading">
<div class="spinner"></div>
<span>Lade Aufgaben</span>
</div>
<AsyncStatePanel
v-if="taskBoard.query.isLoading.value && !taskBoard.query.data.value"
state="loading"
title="Aufgaben werden geladen"
/>
<div v-else-if="taskBoard.query.isError.value" class="board-loading" role="alert">
<AlertTriangle :size="18" />
<span>Das Task Board konnte nicht geladen werden.</span>
<button type="button" class="btn-ghost" @click="taskBoard.query.refetch()">Erneut versuchen</button>
</div>
<AsyncStatePanel
v-else-if="taskBoard.query.isError.value && !taskBoard.query.data.value"
state="error"
title="Task Board konnte nicht geladen werden"
:problem="taskBoard.query.error.value"
@action="taskBoard.query.refetch()"
/>
<div v-else class="board-columns">
<AsyncStatePanel
v-if="taskBoard.query.isError.value && taskBoard.query.data.value"
state="stale"
title="Letzter bestätigter Board-Stand"
:problem="taskBoard.query.error.value"
message="Die sichtbaren Karten bleiben erhalten, während Nexus die Verbindung erneut prüft."
action-label="Erneut synchronisieren"
compact
@action="taskBoard.query.refetch()"
/>
<div v-if="taskBoard.query.data.value" class="board-columns">
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'offen' }"
+14 -11
View File
@@ -26,6 +26,7 @@ import {
import { reconcileTaskBoardCard } from '../api/taskBoard'
import { useAuthStore } from '../stores/auth'
import { reportOperationEnvelope } from '../services/operationResults'
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
import {
buildTaskAgentOptions,
taskAgentLabel,
@@ -411,18 +412,20 @@ function handleKeydown(e: KeyboardEvent) {
Zurück zum Board
</button>
<!-- Loading -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<span>Lade Aufgabe</span>
</div>
<AsyncStatePanel
v-if="loading && !task"
state="loading"
title="Aufgabe wird geladen"
/>
<!-- Error -->
<div v-else-if="error" class="error-state">
<AlertCircle :size="32" />
<p>{{ error }}</p>
<button type="button" class="btn-primary" @click="loadTask">Erneut versuchen</button>
</div>
<AsyncStatePanel
v-else-if="error && !task"
state="error"
title="Aufgabe konnte nicht geladen werden"
:problem="taskDetail.taskQuery.error.value"
:message="error"
@action="loadTask"
/>
<!-- Task Detail -->
<template v-else-if="task">
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { AppProblem, ApiProblem, asProblemDetails, toAppProblem } from '../src/api/contracts'
describe('AppProblem', () => {
it('preserves structured conflict recovery metadata', () => {
const problem = new AppProblem(409, {
code: 'conflict',
detail: 'The resource changed.',
traceId: 'trace-1',
operationId: 'operation-1',
currentRevision: 7,
}, 'fallback')
expect(problem.kind).toBe('conflict')
expect(problem.recoveryAction).toBe('reload-conflict')
expect(problem.traceId).toBe('trace-1')
expect(problem.operationId).toBe('operation-1')
expect(problem.currentRevision).toBe(7)
})
it('maps network failures to a recoverable offline state', () => {
const problem = toAppProblem(new TypeError('Failed to fetch'))
expect(problem.kind).toBe('offline')
expect(problem.recoveryAction).toBe('inspect-connection')
})
it('does not turn missing conflict metadata into revision zero', () => {
const problem = new AppProblem(409, { code: 'conflict' }, 'fallback')
expect(problem.currentRevision).toBeNull()
expect(problem.retryAfterSeconds).toBeNull()
})
it('keeps the legacy ApiProblem compatible with the common contract', () => {
const problem = new ApiProblem(401, { detail: 'Session expired.' }, 'fallback')
expect(problem).toBeInstanceOf(AppProblem)
expect(problem.kind).toBe('authentication')
expect(problem.recoveryAction).toBe('reauthenticate')
})
it('normalizes a legacy message payload at the frontend boundary', () => {
const problem = asProblemDetails({ message: 'Gateway unavailable.' }, 503)
expect(problem?.detail).toBe('Gateway unavailable.')
expect(problem?.status).toBe(503)
})
it('normalizes a legacy error payload without replacing structured details', () => {
expect(asProblemDetails({ error: 'Legacy error.' }, 400)?.detail).toBe('Legacy error.')
expect(asProblemDetails({ detail: 'Canonical.', error: 'Legacy.' }, 409)?.detail).toBe('Canonical.')
})
it.each([
[401, 'authentication', 'reauthenticate'],
[403, 'permission', 'none'],
[409, 'conflict', 'reload-conflict'],
[429, 'rate-limit', 'retry'],
[503, 'offline', 'inspect-connection'],
[504, 'timeout', 'retry'],
] as const)(
'maps HTTP %s to the %s recovery presentation',
(status, kind, recoveryAction) => {
const problem = new AppProblem(status, null, `HTTP ${status}`)
expect(problem.kind).toBe(kind)
expect(problem.recoveryAction).toBe(recoveryAction)
},
)
})
+7
View File
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature",
"allowPrerelease": false
}
}