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
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 ❌"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<NexusVersion>$([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)VERSION').Trim())</NexusVersion>
|
||||
<Version>$(NexusVersion)</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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,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
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
+6
-2
@@ -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,6 +43,7 @@ if (!isOpenApiGeneration)
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
if (!isOpenApiGeneration)
|
||||
app.MapMcp("/mcp");
|
||||
app.MapOpenApi("/openapi/{documentName}.json");
|
||||
app.MapControllers();
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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* ./
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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
@@ -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,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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' }"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestFeature",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user