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
|
docker compose --env-file /tmp/nexus-deploy-env ps
|
||||||
' < "$ENV_TMPFILE"
|
' < "$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"
|
echo "Verifying image provenance"
|
||||||
for container in nexus-api-1 nexus-web-1; do
|
for container in nexus-api-1 nexus-web-1; do
|
||||||
revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")"
|
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"
|
echo "$container provenance verified: v$version $revision"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "Checking live health"
|
echo "Checking public readiness and dependency health"
|
||||||
health_is_healthy() {
|
readiness_is_healthy() {
|
||||||
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health")" || return 1
|
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" |
|
printf '%s' "$health_body" |
|
||||||
grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'
|
grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'
|
||||||
}
|
}
|
||||||
@@ -150,8 +169,8 @@ health_is_healthy() {
|
|||||||
retry=0
|
retry=0
|
||||||
while [ "$retry" -lt 6 ]; do
|
while [ "$retry" -lt 6 ]; do
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
if health_is_healthy; then
|
if readiness_is_healthy && runtime_is_healthy; then
|
||||||
echo "Health check passed with Healthy runtime and database state"
|
echo "Readiness and full dependency health passed"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if [ "$retry" -eq 6 ]; then
|
if [ "$retry" -eq 6 ]; then
|
||||||
@@ -177,8 +196,8 @@ check() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_health() {
|
check_health() {
|
||||||
if health_is_healthy; then
|
if readiness_is_healthy && runtime_is_healthy; then
|
||||||
printf '%-28s HTTP 200 (Healthy)\n' "Health"
|
printf '%-28s HTTP 200 (Ready + Healthy)\n' "Health"
|
||||||
pass=$((pass + 1))
|
pass=$((pass + 1))
|
||||||
else
|
else
|
||||||
printf '%-28s not healthy\n' "Health"
|
printf '%-28s not healthy\n' "Health"
|
||||||
|
|||||||
+50
-24
@@ -35,6 +35,14 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
|
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
|
- name: Verify OpenAPI contract
|
||||||
run: |
|
run: |
|
||||||
test -f backend/openapi/Nexus.Api.json
|
test -f backend/openapi/Nexus.Api.json
|
||||||
@@ -43,17 +51,36 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
||||||
|
|
||||||
- name: Docker integration tests
|
backend-integration:
|
||||||
if: ${{ vars.NEXUS_RUN_DOCKER_INTEGRATION_TESTS == 'true' }}
|
name: Backend integration (PostgreSQL/Toxiproxy)
|
||||||
timeout-minutes: 15
|
runs-on: linux
|
||||||
|
timeout-minutes: 20
|
||||||
env:
|
env:
|
||||||
NEXUS_RUN_DOCKER_INTEGRATION_TESTS: "true"
|
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: >-
|
run: >-
|
||||||
dotnet test backend-tests/Nexus.Api.Tests.csproj
|
dotnet test backend-tests/Nexus.Api.Tests.csproj
|
||||||
--no-build
|
--no-build
|
||||||
--configuration Release
|
--configuration Release
|
||||||
--filter "Category=DockerIntegration"
|
--filter "Category=DockerIntegration|Category=ToxiproxyIntegration"
|
||||||
--verbosity normal
|
--verbosity normal
|
||||||
|
|
||||||
# ─── Frontend ──────────────────────────────────
|
# ─── Frontend ──────────────────────────────────
|
||||||
@@ -78,6 +105,14 @@ jobs:
|
|||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: frontend
|
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
|
- name: Type check
|
||||||
run: pnpm typecheck
|
run: pnpm typecheck
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -112,31 +147,22 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check for .env leaks
|
- name: Gitleaks v8.30.1
|
||||||
|
env:
|
||||||
|
GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Scanning for potential secrets in source code..."
|
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
|
||||||
HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
echo "$GITLEAKS_SHA256 /tmp/gitleaks.tar.gz" | sha256sum -c -
|
||||||
if [ -n "$HITS" ]; then
|
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
|
||||||
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
|
/tmp/gitleaks git --redact --no-banner --exit-code 1 .
|
||||||
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
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Nexus
|
name: Deploy Nexus
|
||||||
runs-on: linux
|
runs-on: linux
|
||||||
needs: [backend, frontend, security]
|
needs: [backend, backend-integration, frontend, security]
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|||||||
@@ -188,8 +188,16 @@ jobs:
|
|||||||
WAIT=1
|
WAIT=1
|
||||||
while [ $RETRY -lt $MAX ]; do
|
while [ $RETRY -lt $MAX ]; do
|
||||||
RETRY=$((RETRY + 1))
|
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)
|
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 ""
|
||||||
echo "✅ Health check passed with Healthy runtime and database state (attempt $RETRY/$MAX)"
|
echo "✅ Health check passed with Healthy runtime and database state (attempt $RETRY/$MAX)"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -227,11 +235,12 @@ jobs:
|
|||||||
}
|
}
|
||||||
|
|
||||||
check_health() {
|
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)
|
body=$(curl -sf --max-time 10 "${BASE}/health" || true)
|
||||||
printf " %-25s" "Health API:"
|
printf " %-25s" "Health API:"
|
||||||
if printf '%s' "$body" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then
|
if { [ "$ready_code" = "200" ] || [ "$ready_code" = "404" ]; } && printf '%s' "$body" | grep -Eq '^[[:space:]]*\{[[:space:]]*"status"[[:space:]]*:[[:space:]]*"Healthy"'; then
|
||||||
echo " HTTP 200 Healthy ✅"
|
echo " ready=${ready_code} full=Healthy ✅"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo " not healthy ❌"
|
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`;
|
> Gitea run 362 deployed commit `144edf58fe5928a3e04816f4435ea574d64211a1`;
|
||||||
> PostgreSQL and the OpenClaw HTTP runtime are healthy. Productive Protocol-v4
|
> PostgreSQL and the OpenClaw HTTP runtime are healthy. Productive Protocol-v4
|
||||||
> management remains intentionally blocked by the external Client-ID gate.
|
> 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
|
> 📋 **Architektur-Review** (2026-06-22): Board-first Orchestrierung, sichere
|
||||||
> Backend-Brücke und Gateway-Integration geprüft. Siehe
|
> 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
|
- keyset-paginated Task Board with targeted live card reconciliation
|
||||||
- structured mutation results with entity references, trace metadata and
|
- structured mutation results with entity references, trace metadata and
|
||||||
cross-page frontend deep links
|
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
|
- Responsive dark-mode operations dashboard
|
||||||
- Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
|
- 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.
|
# BOOTSTRAP_OWNER_EMAIL, BOOTSTRAP_OWNER_PASSWORD and the OpenClaw credential.
|
||||||
# Pin OPENCLAW_REQUIRED_VERSION for a production deployment.
|
# Pin OPENCLAW_REQUIRED_VERSION for a production deployment.
|
||||||
docker compose up --build -d
|
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
|
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.
|
- 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.
|
- 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.
|
- 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).
|
- 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
|
- The API uses an authenticated-by-default fallback policy; only the login and
|
||||||
recovery flow plus the explicit liveness/Gateway-health probes are anonymous.
|
recovery flow plus the explicit liveness/Gateway-health probes are anonymous.
|
||||||
- Swagger is enabled only in the Development environment.
|
- 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
|
### Security
|
||||||
|
|
||||||
@@ -464,8 +483,9 @@ Response-Format (TaskBridgeCommandResponse<T>):
|
|||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/health` | No | Health check with runtime + PostgreSQL |
|
| `GET` | `/health/live` | No | Process liveness only; no dependency probe |
|
||||||
| `GET` | `/api/v1/auth/csrf` | No | Get CSRF token |
|
| `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/login` | No (rate-limited) | Login with email/password |
|
||||||
| `POST` | `/api/v1/auth/refresh` | No (rate-limited) | Refresh access token |
|
| `POST` | `/api/v1/auth/refresh` | No (rate-limited) | Refresh access token |
|
||||||
| `POST` | `/api/v1/auth/logout` | No | Clear refresh 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 |
|
| `PATCH` | `/api/v1/auth/profile` | Yes | Update display name |
|
||||||
| `POST` | `/api/v1/auth/change-password` | Yes | Change password (min 10 chars) |
|
| `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
|
### Operations
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
@@ -209,6 +210,47 @@ public sealed class AuthServiceTests
|
|||||||
Assert.Equal(originalHash, updated.PasswordHash);
|
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
|
// 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.Controllers;
|
||||||
using Nexus.Api.Extensions;
|
using Nexus.Api.Extensions;
|
||||||
using Nexus.Api.Middleware;
|
using Nexus.Api.Middleware;
|
||||||
|
using Nexus.Api.Security;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -90,7 +91,6 @@ public sealed class SecurityBoundaryTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(nameof(AuthController.GetCsrfToken))]
|
|
||||||
[InlineData(nameof(AuthController.Login))]
|
[InlineData(nameof(AuthController.Login))]
|
||||||
[InlineData(nameof(AuthController.Refresh))]
|
[InlineData(nameof(AuthController.Refresh))]
|
||||||
[InlineData(nameof(AuthController.Logout))]
|
[InlineData(nameof(AuthController.Logout))]
|
||||||
@@ -104,6 +104,7 @@ public sealed class SecurityBoundaryTests
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(nameof(HealthController.Live))]
|
[InlineData(nameof(HealthController.Live))]
|
||||||
|
[InlineData(nameof(HealthController.Ready))]
|
||||||
[InlineData(nameof(HealthController.Get))]
|
[InlineData(nameof(HealthController.Get))]
|
||||||
public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName)
|
public void PublicHealthEndpoints_AreExplicitlyAnonymous(string methodName)
|
||||||
{
|
{
|
||||||
@@ -123,6 +124,34 @@ public sealed class SecurityBoundaryTests
|
|||||||
Assert.Empty(method!.GetCustomAttributes(typeof(AllowAnonymousAttribute), inherit: true));
|
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]
|
[Fact]
|
||||||
public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication()
|
public async Task AgentIdentityHeader_IsRejectedWithoutVerifiedAuthentication()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Http;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
@@ -74,7 +75,9 @@ public class AdminController(
|
|||||||
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
|
var normalizedEmail = AuthService.NormalizeEmail(request.Email);
|
||||||
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
|
var existing = await userRepository.GetByEmailAsync(normalizedEmail, ct);
|
||||||
if (existing is not null)
|
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
|
var user = new NexusUser
|
||||||
{
|
{
|
||||||
@@ -108,7 +111,9 @@ public class AdminController(
|
|||||||
{
|
{
|
||||||
var user = await userRepository.GetByIdAsync(id, ct);
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
if (user is null)
|
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))
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
return Results.Problem("Owner accounts cannot be deleted via API.", statusCode: 403);
|
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);
|
var user = await userRepository.GetByIdAsync(id, ct);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
return Results.NotFound(new { error = "User not found." });
|
return NexusHttpResults.Problem(
|
||||||
|
StatusCodes.Status404NotFound,
|
||||||
|
"User not found.");
|
||||||
|
|
||||||
// Niemals owner überschreiben
|
// Niemals owner überschreiben
|
||||||
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(user.Role, "owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Diagnostics;
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Http;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
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)
|
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (request.Content is null)
|
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))
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -170,7 +177,10 @@ public class AgentsController(
|
|||||||
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
|
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault()?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(idempotencyKey))
|
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(
|
var invocation = OpenClawInvocationContext.Create(
|
||||||
@@ -204,15 +214,15 @@ public class AgentsController(
|
|||||||
}
|
}
|
||||||
catch (OpenClawAgentConfigurationConflictException ex)
|
catch (OpenClawAgentConfigurationConflictException ex)
|
||||||
{
|
{
|
||||||
return Results.Json(
|
return NexusHttpResults.Problem(
|
||||||
new
|
StatusCodes.Status409Conflict,
|
||||||
|
ex.Message,
|
||||||
|
NexusProblemCodes.Conflict,
|
||||||
|
extensions: new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
code = ex.Code,
|
["expectedHash"] = ex.ExpectedHash,
|
||||||
message = ex.Message,
|
["currentHash"] = ex.CurrentHash
|
||||||
ex.ExpectedHash,
|
});
|
||||||
ex.CurrentHash
|
|
||||||
},
|
|
||||||
statusCode: StatusCodes.Status409Conflict);
|
|
||||||
}
|
}
|
||||||
catch (OpenClawAgentConfigurationValidationException ex)
|
catch (OpenClawAgentConfigurationValidationException ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Antiforgery;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Http;
|
||||||
using Nexus.Api.RateLimiting;
|
using Nexus.Api.RateLimiting;
|
||||||
|
using Nexus.Api.Security;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
@@ -14,19 +15,10 @@ namespace Nexus.Api.Controllers;
|
|||||||
[Route("api/v1/auth")]
|
[Route("api/v1/auth")]
|
||||||
public class AuthController(
|
public class AuthController(
|
||||||
IAuthService authService,
|
IAuthService authService,
|
||||||
IAntiforgery antiforgery,
|
|
||||||
IConfiguration config,
|
IConfiguration config,
|
||||||
IHostEnvironment env,
|
IHostEnvironment env,
|
||||||
LoginAttemptTracker attemptTracker) : ControllerBase
|
LoginAttemptTracker attemptTracker) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("csrf")]
|
|
||||||
[AllowAnonymous]
|
|
||||||
public IActionResult GetCsrfToken()
|
|
||||||
{
|
|
||||||
var tokens = antiforgery.GetAndStoreTokens(HttpContext);
|
|
||||||
return Ok(new { token = tokens.RequestToken });
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[EnableRateLimiting("auth")]
|
[EnableRateLimiting("auth")]
|
||||||
@@ -51,14 +43,16 @@ public class AuthController(
|
|||||||
HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
HttpContext.Response.Headers["X-RateLimit-Reset"] =
|
||||||
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
DateTimeOffset.UtcNow.AddSeconds(retryAfterSeconds).ToUnixTimeSeconds().ToString();
|
||||||
|
|
||||||
// Return a structured body so the frontend can display remaining attempts
|
return Results.Problem(
|
||||||
return Results.Json(new
|
statusCode: StatusCodes.Status401Unauthorized,
|
||||||
|
title: "Authentication failed",
|
||||||
|
detail: "Invalid email or password.",
|
||||||
|
extensions: new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
error = "invalid_credentials",
|
["code"] = NexusProblemCodes.Unauthenticated,
|
||||||
message = "Invalid email or password.",
|
["remaining"] = remaining,
|
||||||
remaining,
|
["retryAfterSeconds"] = retryAfterSeconds
|
||||||
retryAfterSeconds
|
});
|
||||||
}, statusCode: 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success — reset attempt counter
|
// Success — reset attempt counter
|
||||||
@@ -75,14 +69,17 @@ public class AuthController(
|
|||||||
[EnableRateLimiting("auth")]
|
[EnableRateLimiting("auth")]
|
||||||
public async Task<IResult> Refresh(CancellationToken ct)
|
public async Task<IResult> Refresh(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (!BrowserRequestOriginGuard.IsAllowed(Request))
|
||||||
|
return CrossSiteRequestRejected();
|
||||||
|
|
||||||
if (!Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
|
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);
|
var session = await authService.RefreshAsync(refreshToken!, ct);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
ClearRefreshCookie(Response);
|
ClearRefreshCookie(Response);
|
||||||
return Results.Unauthorized();
|
return Unauthenticated("The refresh session is invalid or expired.");
|
||||||
}
|
}
|
||||||
|
|
||||||
SetRefreshCookie(Response, session.RefreshToken);
|
SetRefreshCookie(Response, session.RefreshToken);
|
||||||
@@ -96,6 +93,9 @@ public class AuthController(
|
|||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<IResult> Logout(CancellationToken ct)
|
public async Task<IResult> Logout(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (!BrowserRequestOriginGuard.IsAllowed(Request))
|
||||||
|
return CrossSiteRequestRejected();
|
||||||
|
|
||||||
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
|
if (Request.Cookies.TryGetValue("nexus_refresh", out var refreshToken))
|
||||||
await authService.RevokeAsync(refreshToken!, ct);
|
await authService.RevokeAsync(refreshToken!, ct);
|
||||||
|
|
||||||
@@ -168,6 +168,26 @@ public class AuthController(
|
|||||||
User = session.User
|
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)
|
private void SetRefreshCookie(HttpResponse response, string token)
|
||||||
{
|
{
|
||||||
var days = config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
|
var days = config.GetValue<int?>("Jwt:RefreshTokenExpirationDays") ?? 7;
|
||||||
|
|||||||
@@ -18,6 +18,26 @@ public class HealthController(IAgentRuntime runtime, HealthCheckService healthCh
|
|||||||
agentSource = "openclaw-rpc"
|
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]
|
[AllowAnonymous]
|
||||||
[HttpGet("/health")]
|
[HttpGet("/health")]
|
||||||
public async Task<IResult> Get(CancellationToken ct)
|
public async Task<IResult> Get(CancellationToken ct)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Nexus.Api.Models;
|
using Nexus.Api.Http;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
@@ -28,21 +28,32 @@ internal static class OpenClawContentReadEndpoint
|
|||||||
"disconnected" => StatusCodes.Status503ServiceUnavailable,
|
"disconnected" => StatusCodes.Status503ServiceUnavailable,
|
||||||
_ => StatusCodes.Status409Conflict
|
_ => StatusCodes.Status409Conflict
|
||||||
};
|
};
|
||||||
return Results.Json(
|
return NexusHttpResults.Problem(
|
||||||
new OpenClawAgentConfigurationErrorDto(
|
status,
|
||||||
exception.State,
|
|
||||||
exception.Message,
|
exception.Message,
|
||||||
exception.Method,
|
status switch
|
||||||
exception.RequiredScope),
|
{
|
||||||
statusCode: status);
|
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)
|
catch (OpenClawAgentConfigurationVerificationException)
|
||||||
{
|
{
|
||||||
return Results.Json(
|
return NexusHttpResults.Problem(
|
||||||
new OpenClawAgentConfigurationErrorDto(
|
StatusCodes.Status502BadGateway,
|
||||||
"verification_failed",
|
"OpenClaw-Antwort konnte nicht sicher verifiziert werden.",
|
||||||
"OpenClaw-Antwort konnte nicht sicher verifiziert werden."),
|
NexusProblemCodes.DependencyUnavailable,
|
||||||
statusCode: StatusCodes.Status502BadGateway);
|
extensions: new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["state"] = "verification_failed"
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (OpenClawGatewayRpcException exception)
|
catch (OpenClawGatewayRpcException exception)
|
||||||
{
|
{
|
||||||
@@ -60,9 +71,16 @@ internal static class OpenClawContentReadEndpoint
|
|||||||
StatusCodes.Status504GatewayTimeout,
|
StatusCodes.Status504GatewayTimeout,
|
||||||
_ => StatusCodes.Status502BadGateway
|
_ => StatusCodes.Status502BadGateway
|
||||||
};
|
};
|
||||||
return Results.Json(
|
var problemCode = status switch
|
||||||
new OpenClawAgentConfigurationErrorDto(
|
{
|
||||||
code.ToLowerInvariant(),
|
StatusCodes.Status403Forbidden => NexusProblemCodes.Forbidden,
|
||||||
|
StatusCodes.Status409Conflict => NexusProblemCodes.UnsupportedCapability,
|
||||||
|
StatusCodes.Status503ServiceUnavailable => NexusProblemCodes.DependencyUnavailable,
|
||||||
|
StatusCodes.Status504GatewayTimeout => NexusProblemCodes.Timeout,
|
||||||
|
_ => NexusProblemCodes.DependencyUnavailable
|
||||||
|
};
|
||||||
|
return NexusHttpResults.Problem(
|
||||||
|
status,
|
||||||
status switch
|
status switch
|
||||||
{
|
{
|
||||||
StatusCodes.Status403Forbidden =>
|
StatusCodes.Status403Forbidden =>
|
||||||
@@ -74,8 +92,12 @@ internal static class OpenClawContentReadEndpoint
|
|||||||
StatusCodes.Status504GatewayTimeout =>
|
StatusCodes.Status504GatewayTimeout =>
|
||||||
"OpenClaw hat nicht rechtzeitig geantwortet.",
|
"OpenClaw hat nicht rechtzeitig geantwortet.",
|
||||||
_ => "OpenClaw-Leseoperation ist fehlgeschlagen."
|
_ => "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
|
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
|
||||||
|
ARG NEXUS_VERSION=dev
|
||||||
|
ARG NEXUS_GIT_SHA=unknown
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY Nexus.Api.csproj .
|
COPY Nexus.Api.csproj .
|
||||||
RUN dotnet restore
|
RUN dotnet restore
|
||||||
COPY . .
|
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
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||||
ARG NEXUS_VERSION=dev
|
ARG NEXUS_VERSION=dev
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using Nexus.Api.Observability;
|
using Nexus.Api.Observability;
|
||||||
|
using Nexus.Api.Http;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using OpenTelemetry;
|
using OpenTelemetry;
|
||||||
using OpenTelemetry.Exporter;
|
using OpenTelemetry.Exporter;
|
||||||
@@ -20,14 +21,14 @@ public static class PlatformServiceCollectionExtensions
|
|||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddOpenApi("v1");
|
services.AddOpenApi("v1", options =>
|
||||||
|
options.AddSchemaTransformer<NexusProblemDetailsSchemaTransformer>());
|
||||||
services.AddProblemDetails(options =>
|
services.AddProblemDetails(options =>
|
||||||
{
|
{
|
||||||
options.CustomizeProblemDetails = context =>
|
options.CustomizeProblemDetails = context =>
|
||||||
{
|
NexusProblemDetailsDefaults.Apply(
|
||||||
context.ProblemDetails.Extensions["traceId"] =
|
context.ProblemDetails,
|
||||||
Activity.Current?.Id ?? context.HttpContext.TraceIdentifier;
|
context.HttpContext);
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var telemetry = services.AddOpenTelemetry()
|
var telemetry = services.AddOpenTelemetry()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using Microsoft.IdentityModel.Tokens;
|
|||||||
using ModelContextProtocol.AspNetCore;
|
using ModelContextProtocol.AspNetCore;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Http;
|
||||||
using Nexus.Api.RateLimiting;
|
using Nexus.Api.RateLimiting;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Routing;
|
using Nexus.Api.Routing;
|
||||||
@@ -28,7 +29,7 @@ namespace Nexus.Api.Extensions;
|
|||||||
public static class ServiceCollectionExtensions
|
public static class ServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Configures JWT authentication, authorization, and antiforgery.
|
/// Configures JWT authentication and authorization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
@@ -63,14 +64,6 @@ public static class ServiceCollectionExtensions
|
|||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.Build();
|
.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;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +102,7 @@ public static class ServiceCollectionExtensions
|
|||||||
Status = StatusCodes.Status429TooManyRequests,
|
Status = StatusCodes.Status429TooManyRequests,
|
||||||
Detail = $"Too many attempts. Try again in {retryAfterSeconds} second(s)."
|
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["remaining"] = 0;
|
||||||
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
|
body.Extensions["retryAfterSeconds"] = retryAfterSeconds;
|
||||||
body.Extensions["traceId"] =
|
body.Extensions["traceId"] =
|
||||||
@@ -272,11 +265,15 @@ public static class ServiceCollectionExtensions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddNexusApplicationServices(
|
public static IServiceCollection AddNexusApplicationServices(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
bool includeHostedServices = true)
|
bool includeHostedServices = true,
|
||||||
|
bool includeMcp = true)
|
||||||
|
{
|
||||||
|
if (includeMcp)
|
||||||
{
|
{
|
||||||
services.AddMcpServer()
|
services.AddMcpServer()
|
||||||
.WithHttpTransport(options => options.Stateless = true)
|
.WithHttpTransport(options => options.Stateless = true)
|
||||||
.WithTools<NexusMcpTools>();
|
.WithTools<NexusMcpTools>();
|
||||||
|
}
|
||||||
|
|
||||||
services.AddOptions<StaleTaskRecoveryOptions>()
|
services.AddOptions<StaleTaskRecoveryOptions>()
|
||||||
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
|
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
|
||||||
@@ -364,7 +361,10 @@ public static class ServiceCollectionExtensions
|
|||||||
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddHealthChecks()
|
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"]);
|
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
||||||
|
|
||||||
return services;
|
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.Extensions;
|
||||||
|
using Nexus.Api.Http;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -20,11 +21,13 @@ builder.Services.AddNexusSwagger();
|
|||||||
builder.Services.AddNexusDatabase(builder.Configuration);
|
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||||
builder.Services.AddNexusHttpClients(builder.Configuration);
|
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||||
builder.Services.AddNexusApplicationServices(
|
builder.Services.AddNexusApplicationServices(
|
||||||
includeHostedServices: !isOpenApiGeneration);
|
includeHostedServices: !isOpenApiGeneration,
|
||||||
|
includeMcp: !isOpenApiGeneration);
|
||||||
builder.Services.AddNexusRepositories();
|
builder.Services.AddNexusRepositories();
|
||||||
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||||
builder.Services.AddNexusPlatform(builder.Configuration);
|
builder.Services.AddNexusPlatform(builder.Configuration);
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers(options =>
|
||||||
|
options.Filters.Add<NexusProblemDetailsFilter>());
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ if (!isOpenApiGeneration)
|
|||||||
// --- Middleware Pipeline ---
|
// --- Middleware Pipeline ---
|
||||||
app.UseNexusPipeline(app.Environment);
|
app.UseNexusPipeline(app.Environment);
|
||||||
|
|
||||||
|
if (!isOpenApiGeneration)
|
||||||
app.MapMcp("/mcp");
|
app.MapMcp("/mcp");
|
||||||
app.MapOpenApi("/openapi/{documentName}.json");
|
app.MapOpenApi("/openapi/{documentName}.json");
|
||||||
app.MapControllers();
|
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": {
|
"/api/v1/auth/login": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -3023,6 +3011,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/health/ready": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Health"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -15008,6 +15011,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ProblemDetails": {
|
"ProblemDetails": {
|
||||||
|
"required": [
|
||||||
|
"code",
|
||||||
|
"traceId"
|
||||||
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
@@ -15042,6 +15049,65 @@
|
|||||||
"null",
|
"null",
|
||||||
"string"
|
"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": {
|
"ValidationProblemDetails": {
|
||||||
|
"required": [
|
||||||
|
"code",
|
||||||
|
"traceId"
|
||||||
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
@@ -15942,6 +16012,65 @@
|
|||||||
"type": "string"
|
"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
|
- host.docker.internal:host-gateway
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -115,7 +115,7 @@ services:
|
|||||||
- "127.0.0.1:18880:80"
|
- "127.0.0.1:18880:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
restart: true
|
restart: true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Nexus Agent-First Mission Control
|
# 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
|
**Stand:** 2026-07-31
|
||||||
**Geltungsbereich:** Produkt, Frontend, Backend, Runtime-Adapter und Agenten-Schnittstellen
|
**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
|
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).
|
[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
|
## 4. Prioritätsdefinition
|
||||||
|
|
||||||
- **P0:** Blockiert sicheren End-to-End-Betrieb oder das Kernversprechen
|
- **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.
|
- Last-, Reconnect-, Timeout-, Queue-, Recovery- und Chaos-Szenarien prüfen.
|
||||||
- Runbooks, Backup/Restore und Rollback regelmäßig als Game Day testen.
|
- 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
|
**Abnahme:** Ein Release ist nur möglich, wenn technische Checks und ein
|
||||||
repräsentativer realer Agenten-Workflow gemeinsam grün sind.
|
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
|
versions, sanitized output, dataset provenance, and timestamp for every
|
||||||
acceptance run.
|
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
|
## Task Board load gate
|
||||||
|
|
||||||
The full k6 profile holds ten virtual users for two minutes and fails when:
|
The full k6 profile holds ten virtual users for two minutes and fails when:
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Nexus – Security Spot Check
|
# 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
|
**Datum:** 2026-07-26
|
||||||
**Commit:** `3bc7622977f4a6c2f2e98ab4aa856a2e45c3cf49`
|
**Commit:** `3bc7622977f4a6c2f2e98ab4aa856a2e45c3cf49`
|
||||||
**Vertraulichkeit:** Intern
|
**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)
|
- JWT-Sicherheit entspricht Best Practices (PBKDF2-SHA256, 210k Iterationen, Rotating Refresh Tokens)
|
||||||
- Refresh-Token-Reuse-Detection verhindert Token-Theft
|
- Refresh-Token-Reuse-Detection verhindert Token-Theft
|
||||||
- Rate-Limiting auf Login und Refresh
|
- 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)
|
- Security Headers (HSTS, CSP, XFO, Referrer-Policy)
|
||||||
|
|
||||||
**Kritisch:**
|
**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
|
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
|
WORKDIR /app
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
COPY package.json pnpm-lock.yaml* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
|
|||||||
@@ -131,6 +131,33 @@ test.describe('authenticated route and deep-link smoke', () => {
|
|||||||
})).toBeVisible()
|
})).toBeVisible()
|
||||||
|
|
||||||
await expect.poll(() => api.openClawOverviewRequestCount).toBe(1)
|
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 }) => {
|
test('keeps Iris and run mutation entry points unavailable to non-owners', async ({ page }) => {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ interface MockOptions {
|
|||||||
domainResyncSequence?: number
|
domainResyncSequence?: number
|
||||||
boardRefreshDelayMs?: number
|
boardRefreshDelayMs?: number
|
||||||
boardRefreshTitle?: string
|
boardRefreshTitle?: string
|
||||||
|
apiFailure?: {
|
||||||
|
path: string
|
||||||
|
status: number
|
||||||
|
attempts?: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CapturedRequest {
|
interface CapturedRequest {
|
||||||
@@ -40,6 +45,7 @@ export interface NexusApiHarness {
|
|||||||
readonly chatRequests: CapturedRequest[]
|
readonly chatRequests: CapturedRequest[]
|
||||||
readonly resyncResponseCount: number
|
readonly resyncResponseCount: number
|
||||||
readonly openClawOverviewRequestCount: number
|
readonly openClawOverviewRequestCount: number
|
||||||
|
readonly browserTelemetryRequestCount: number
|
||||||
readonly proposal: Record<string, unknown>
|
readonly proposal: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,6 +459,8 @@ export async function mockNexusApi(
|
|||||||
let resyncResponseCount = 0
|
let resyncResponseCount = 0
|
||||||
let boardInitialRequestCount = 0
|
let boardInitialRequestCount = 0
|
||||||
let openClawOverviewRequestCount = 0
|
let openClawOverviewRequestCount = 0
|
||||||
|
let browserTelemetryRequestCount = 0
|
||||||
|
let injectedFailureCount = 0
|
||||||
let openTaskState = 'Backlog'
|
let openTaskState = 'Backlog'
|
||||||
|
|
||||||
await page.route('**/api/**', async route => {
|
await page.route('**/api/**', async route => {
|
||||||
@@ -469,6 +477,23 @@ export async function mockNexusApi(
|
|||||||
return
|
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 (path === '/api/v1/auth/refresh' && method === 'POST') {
|
||||||
if (!authenticated) {
|
if (!authenticated) {
|
||||||
await fulfillJson(route, problem('No active E2E session.', 401), 401)
|
await fulfillJson(route, problem('No active E2E session.', 401), 401)
|
||||||
@@ -861,6 +886,7 @@ export async function mockNexusApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (path === '/api/v1/telemetry/browser') {
|
if (path === '/api/v1/telemetry/browser') {
|
||||||
|
browserTelemetryRequestCount += 1
|
||||||
await route.fulfill({ status: 202 })
|
await route.fulfill({ status: 202 })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1192,6 +1218,9 @@ export async function mockNexusApi(
|
|||||||
get openClawOverviewRequestCount() {
|
get openClawOverviewRequestCount() {
|
||||||
return openClawOverviewRequestCount
|
return openClawOverviewRequestCount
|
||||||
},
|
},
|
||||||
|
get browserTelemetryRequestCount() {
|
||||||
|
return browserTelemetryRequestCount
|
||||||
|
},
|
||||||
get proposal() {
|
get proposal() {
|
||||||
return proposal
|
return proposal
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "nexus-web",
|
"name": "nexus-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.2.60",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export default defineConfig({
|
|||||||
webServer: {
|
webServer: {
|
||||||
command: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
|
command: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
|
env: {
|
||||||
|
VITE_BROWSER_TELEMETRY_ENABLED: 'false',
|
||||||
|
},
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import type { components } from './generated/schema'
|
import type { components } from './generated/schema'
|
||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
import { ApiProblem, type ProblemDetailsDto } from './contracts'
|
import { ApiProblem, asProblemDetails } from './contracts'
|
||||||
import { queryKeys } from './queryClient'
|
import { queryKeys } from './queryClient'
|
||||||
import { createMutationRequestContext } from '../services/mutationContext'
|
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 {
|
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 {
|
export function getAgentProposalStatusMeta(status: string): AgentProposalStatusMeta {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { components } from './generated/schema'
|
||||||
|
|
||||||
export type EntityType =
|
export type EntityType =
|
||||||
| 'activity'
|
| 'activity'
|
||||||
| 'agent'
|
| 'agent'
|
||||||
@@ -57,37 +59,171 @@ export function normalizeOperationResult(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsDto {
|
export type ProblemDetailsDto = Omit<
|
||||||
type?: string
|
components['schemas']['ProblemDetails'],
|
||||||
title?: string
|
| 'status'
|
||||||
|
| 'code'
|
||||||
|
| 'traceId'
|
||||||
|
| 'operationId'
|
||||||
|
| 'currentRevision'
|
||||||
|
| 'retryAfterSeconds'
|
||||||
|
| 'remaining'
|
||||||
|
> & {
|
||||||
status?: number
|
status?: number
|
||||||
detail?: string
|
code?: string
|
||||||
instance?: string
|
|
||||||
traceId?: string
|
traceId?: string
|
||||||
|
operationId?: string
|
||||||
currentRevision?: number
|
currentRevision?: number
|
||||||
|
retryAfterSeconds?: number
|
||||||
|
remaining?: number
|
||||||
errors?: Record<string, string[]>
|
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 status: number
|
||||||
readonly problem: ProblemDetailsDto | null
|
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) {
|
constructor(status: number, problem: ProblemDetailsDto | null, fallback: string) {
|
||||||
super(problem?.detail || problem?.title || fallback)
|
super(problem?.detail || problem?.title || fallback)
|
||||||
this.name = 'ApiProblem'
|
this.name = 'AppProblem'
|
||||||
this.status = status
|
this.status = status
|
||||||
this.problem = problem
|
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> {
|
export async function throwApiProblem(response: Response, fallback: string): Promise<never> {
|
||||||
let problem: ProblemDetailsDto | null = null
|
let problem: ProblemDetailsDto | null = null
|
||||||
try {
|
try {
|
||||||
problem = await response.json() as ProblemDetailsDto
|
problem = asProblemDetails(await response.json(), response.status)
|
||||||
} catch {
|
} catch {
|
||||||
// A structured ProblemDetails response is preferred, but legacy endpoints
|
// A structured ProblemDetails response is preferred, but legacy endpoints
|
||||||
// can still return an empty error body during the migration.
|
// can still return an empty error body during the migration.
|
||||||
}
|
}
|
||||||
throw new ApiProblem(response.status, problem, fallback)
|
throw new ApiProblem(response.status, problem, fallback)
|
||||||
}
|
}
|
||||||
import type { components } from './generated/schema'
|
|
||||||
|
|||||||
+90
-33
@@ -518,39 +518,6 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/v1/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2767,6 +2734,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/health": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8207,6 +8214,31 @@ export interface components {
|
|||||||
status?: null | number | string;
|
status?: null | number | string;
|
||||||
detail?: null | string;
|
detail?: null | string;
|
||||||
instance?: 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: {
|
ProjectDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -8422,6 +8454,31 @@ export interface components {
|
|||||||
errors?: {
|
errors?: {
|
||||||
[key: string]: string[];
|
[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: {
|
VerifyOpenClawRequest: {
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
|||||||
import { useQuery } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import type { paths } from './generated/schema'
|
import type { paths } from './generated/schema'
|
||||||
import { apiClient } from './client'
|
import { apiClient } from './client'
|
||||||
import { ApiProblem, type ProblemDetailsDto } from './contracts'
|
import { ApiProblem, asProblemDetails } from './contracts'
|
||||||
import { queryClient, queryKeys } from './queryClient'
|
import { queryClient, queryKeys } from './queryClient'
|
||||||
import type { AgentNodeData } from '../composables/useFlowLayout'
|
import type { AgentNodeData } from '../composables/useFlowLayout'
|
||||||
|
|
||||||
@@ -27,12 +27,8 @@ export type OpenClawCapabilitiesDto = GeneratedCapabilities
|
|||||||
export type OpenClawAgentsDto = GeneratedAgents
|
export type OpenClawAgentsDto = GeneratedAgents
|
||||||
type OpenClawAgentDto = OpenClawAgentsDto['items'][number]
|
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 {
|
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(
|
export async function fetchOpenClawOverview(
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
import { ListTodo } from '@lucide/vue'
|
import { ListTodo } from '@lucide/vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import type { TaskItem } from './types'
|
import type { TaskItem } from './types'
|
||||||
|
import AsyncStatePanel from '../../mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
tasks: TaskItem[]
|
tasks: TaskItem[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
error?: string | null
|
error?: unknown
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ retry: [] }>()
|
||||||
|
|
||||||
function priorityLabel(priority: TaskItem['priority']): string {
|
function priorityLabel(priority: TaskItem['priority']): string {
|
||||||
return priority === 'high' ? 'P0' : priority === 'medium' ? 'P1' : 'P2'
|
return priority === 'high' ? 'P0' : priority === 'medium' ? 'P1' : 'P2'
|
||||||
}
|
}
|
||||||
@@ -37,14 +40,35 @@ function taskLabel(task: TaskItem): string {
|
|||||||
<span>Fokus</span>
|
<span>Fokus</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="task-list" aria-label="Tasks werden geladen">
|
<AsyncStatePanel
|
||||||
<span v-for="index in 3" :key="index" class="tcard skeleton"></span>
|
v-if="loading && !tasks.length"
|
||||||
</div>
|
class="tstrip-state"
|
||||||
|
state="loading"
|
||||||
|
title="Fokus-Tasks werden geladen"
|
||||||
|
compact
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="error" class="tstrip-msg error">{{ error }}</div>
|
<AsyncStatePanel
|
||||||
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
|
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
|
<RouterLink
|
||||||
v-for="task in tasks.slice(0, 4)"
|
v-for="task in tasks.slice(0, 4)"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
@@ -102,6 +126,11 @@ function taskLabel(task: TaskItem): string {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tstrip-state {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.tcard {
|
.tcard {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 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'
|
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 = {}) {
|
export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {}) {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
if (!auth.initialized) await auth.initialize()
|
if (!auth.initialized) await auth.initialize()
|
||||||
@@ -28,7 +40,7 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
|
|||||||
|
|
||||||
const refreshed = await auth.refresh()
|
const refreshed = await auth.refresh()
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
if (window.location.pathname !== '/login') window.location.assign('/login')
|
redirectToLogin()
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,13 +28,17 @@ const STANDARD_METRICS = new Set<BrowserMetricName>(['CLS', 'FCP', 'INP', 'LCP',
|
|||||||
let activeRouter: Router | null = null
|
let activeRouter: Router | null = null
|
||||||
let started = false
|
let started = false
|
||||||
|
|
||||||
|
export function isBrowserTelemetryEnabled(): boolean {
|
||||||
|
return String(import.meta.env.VITE_BROWSER_TELEMETRY_ENABLED ?? 'true').toLowerCase() !== 'false'
|
||||||
|
}
|
||||||
|
|
||||||
function safeRouteName(): string {
|
function safeRouteName(): string {
|
||||||
const name = activeRouter?.currentRoute.value.name
|
const name = activeRouter?.currentRoute.value.name
|
||||||
return typeof name === 'string' && /^[A-Za-z0-9 _-]{1,80}$/.test(name) ? name : 'unknown'
|
return typeof name === 'string' && /^[A-Za-z0-9 _-]{1,80}$/.test(name) ? name : 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function report(envelope: BrowserMetricEnvelope): Promise<void> {
|
async function report(envelope: BrowserMetricEnvelope): Promise<void> {
|
||||||
if (safeRouteName() === 'Login') return
|
if (!isBrowserTelemetryEnabled() || safeRouteName() === 'Login') return
|
||||||
try {
|
try {
|
||||||
await apiFetch('/api/v1/telemetry/browser', {
|
await apiFetch('/api/v1/telemetry/browser', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -53,7 +57,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null {
|
|||||||
value: metric.value,
|
value: metric.value,
|
||||||
rating: metric.rating,
|
rating: metric.rating,
|
||||||
routeName: safeRouteName(),
|
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),
|
navigationType: String(metric.navigationType || 'navigate').slice(0, 40),
|
||||||
liveMode: 'unknown',
|
liveMode: 'unknown',
|
||||||
correlationId: null,
|
correlationId: null,
|
||||||
@@ -62,7 +66,7 @@ function fromWebVital(metric: Metric): BrowserMetricEnvelope | null {
|
|||||||
|
|
||||||
export function startBrowserTelemetry(router: Router): void {
|
export function startBrowserTelemetry(router: Router): void {
|
||||||
activeRouter = router
|
activeRouter = router
|
||||||
if (started || typeof window === 'undefined') return
|
if (!isBrowserTelemetryEnabled() || started || typeof window === 'undefined') return
|
||||||
started = true
|
started = true
|
||||||
|
|
||||||
const callback = (metric: Metric) => {
|
const callback = (metric: Metric) => {
|
||||||
@@ -91,7 +95,7 @@ export async function reportCustomMetric(
|
|||||||
value,
|
value,
|
||||||
rating: 'custom',
|
rating: 'custom',
|
||||||
routeName: safeRouteName(),
|
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',
|
navigationType: 'spa',
|
||||||
liveMode: options.liveMode ?? 'unknown',
|
liveMode: options.liveMode ?? 'unknown',
|
||||||
correlationId: options.correlationId?.slice(0, 128) ?? null,
|
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>
|
const body = await response.json() as Record<string, unknown>
|
||||||
if (typeof body.remaining === 'number') remaining = body.remaining
|
if (typeof body.remaining === 'number') remaining = body.remaining
|
||||||
if (typeof body.retryAfterSeconds === 'number') retryAfter = body.retryAfterSeconds
|
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) {
|
if (response.status === 429) {
|
||||||
this.remainingAttempts = 0
|
this.remainingAttempts = 0
|
||||||
this.retryAfterSeconds = retryAfter
|
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) {
|
} else if (response.status === 401) {
|
||||||
this.remainingAttempts = remaining
|
this.remainingAttempts = remaining
|
||||||
this.retryAfterSeconds = retryAfter
|
this.retryAfterSeconds = retryAfter
|
||||||
throw new LoginError(
|
throw new LoginError(
|
||||||
body.message as string || 'Invalid email or password.',
|
problemMessage || 'Invalid email or password.',
|
||||||
remaining ?? 4,
|
remaining ?? 4,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from '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 type { EntityRefDto } from '../api/contracts'
|
||||||
import { useActivity } from '../api/activity'
|
import { useActivity } from '../api/activity'
|
||||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||||
import EntityLink from '../components/mission-control/EntityLink.vue'
|
import EntityLink from '../components/mission-control/EntityLink.vue'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
interface UnifiedActivity {
|
interface UnifiedActivity {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,6 +38,7 @@ interface UnifiedActivity {
|
|||||||
const overviewQuery = useOpenClawOverviewQuery()
|
const overviewQuery = useOpenClawOverviewQuery()
|
||||||
const nexusActivity = useActivity()
|
const nexusActivity = useActivity()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const sourceFilter = ref<'all' | 'OpenClaw' | 'Nexus'>('all')
|
const sourceFilter = ref<'all' | 'OpenClaw' | 'Nexus'>('all')
|
||||||
const severityFilter = ref('all')
|
const severityFilter = ref('all')
|
||||||
@@ -220,19 +222,15 @@ onUnmounted(() => {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<AsyncStatePanel
|
||||||
v-if="runtimeCollection && runtimeCollection.state !== 'ready'"
|
v-if="runtimeCollection && runtimeCollection.state !== 'ready'"
|
||||||
class="runtime-state nexus-state"
|
:state="['disconnected', 'error'].includes(runtimeCollection.state) ? 'offline' : 'partial'"
|
||||||
:class="{ 'nexus-state--error': runtimeCollection.state === 'disconnected' || runtimeCollection.state === 'error' }"
|
:title="`OpenClaw-Auditfeed: ${runtimeCollection.state}`"
|
||||||
>
|
:message="runtimeCollection.recovery || runtimeCollection.message || 'Runtime-Aktivität ist derzeit nicht vollständig verfügbar.'"
|
||||||
<ServerCog :size="18" aria-hidden="true" />
|
action-label="Diagnose öffnen"
|
||||||
<div>
|
compact
|
||||||
<h2>OpenClaw audit feed: {{ runtimeCollection.state }}</h2>
|
@action="router.push('/settings')"
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="activity-toolbar nexus-panel" aria-label="Activity filters">
|
<section class="activity-toolbar nexus-panel" aria-label="Activity filters">
|
||||||
<label class="activity-search">
|
<label class="activity-search">
|
||||||
@@ -258,10 +256,11 @@ onUnmounted(() => {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="overviewQuery.isPending.value && !overviewQuery.data.value && nexusActivity.isLoading.value" class="nexus-state nexus-state--loading">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
v-if="overviewQuery.isPending.value && !overviewQuery.data.value && nexusActivity.isLoading.value"
|
||||||
<p>Loading activity sources…</p>
|
state="loading"
|
||||||
</div>
|
title="Aktivitätsquellen werden geladen"
|
||||||
|
/>
|
||||||
<section v-else-if="filteredEvents.length" class="timeline" aria-label="Activity timeline">
|
<section v-else-if="filteredEvents.length" class="timeline" aria-label="Activity timeline">
|
||||||
<button
|
<button
|
||||||
v-for="event in filteredEvents"
|
v-for="event in filteredEvents"
|
||||||
@@ -291,12 +290,12 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<div v-else class="nexus-state nexus-state--empty">
|
<AsyncStatePanel
|
||||||
<Activity :size="18" aria-hidden="true" />
|
v-else
|
||||||
<h2>No matching activity</h2>
|
state="empty"
|
||||||
<p v-if="events.length">Adjust the search or source filters.</p>
|
title="Keine passende Aktivität"
|
||||||
<p v-else>No Nexus or OpenClaw events have been returned yet.</p>
|
:message="events.length ? 'Passe Suche oder Quellenfilter an.' : 'Nexus und OpenClaw haben noch keine Ereignisse geliefert.'"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="selectedEvent" class="activity-dialog-overlay" @click.self="closeDetails">
|
<div v-if="selectedEvent" class="activity-dialog-overlay" @click.self="closeDetails">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
type CreateAgentProposalRequest,
|
type CreateAgentProposalRequest,
|
||||||
} from '../api/agentProposals'
|
} from '../api/agentProposals'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
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>
|
<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>
|
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="optionsQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
|
v-else-if="optionsQuery.isPending.value"
|
||||||
OpenClaw-Optionen werden geprüft…
|
state="loading"
|
||||||
</div>
|
title="OpenClaw-Optionen werden geprüft"
|
||||||
<div v-else-if="optionsQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
|
/>
|
||||||
<strong>Optionen nicht verfügbar</strong>
|
<AsyncStatePanel
|
||||||
<span>{{ optionsQuery.error.value?.message || 'Die Create-Options konnten nicht geladen werden.' }}</span>
|
v-else-if="optionsQuery.isError.value"
|
||||||
<button type="button" class="nexus-button" @click="optionsQuery.refetch()">Erneut prüfen</button>
|
state="error"
|
||||||
</div>
|
title="Agent-Optionen nicht verfügbar"
|
||||||
|
:problem="optionsQuery.error.value"
|
||||||
|
action-label="Erneut prüfen"
|
||||||
|
@action="optionsQuery.refetch()"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-else-if="options">
|
<template v-else-if="options">
|
||||||
<section
|
<section
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
import { onMounted, onUnmounted, ref, computed, watch } from 'vue'
|
||||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
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 { apiFetch } from '../services/api'
|
||||||
import {
|
import {
|
||||||
applyAgentFileWrite,
|
applyAgentFileWrite,
|
||||||
@@ -22,6 +22,7 @@ import StandingOrdersEditor from '../components/config/StandingOrdersEditor.vue'
|
|||||||
import { subscribeDomainEventState } from '../services/domainEvents'
|
import { subscribeDomainEventState } from '../services/domainEvents'
|
||||||
import { createMutationRequestContext } from '../services/mutationContext'
|
import { createMutationRequestContext } from '../services/mutationContext'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -74,15 +75,6 @@ const configFiles = computed(() => filesQuery.data.value?.files ?? [])
|
|||||||
const loading = computed(() => agentQuery.isPending.value)
|
const loading = computed(() => agentQuery.isPending.value)
|
||||||
const activityLoading = computed(() => activityQuery.isFetching.value)
|
const activityLoading = computed(() => activityQuery.isFetching.value)
|
||||||
const summaryLoading = computed(() => summaryQuery.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(() => {
|
const currentFile = computed(() => {
|
||||||
if (!configFiles.value.length) return null
|
if (!configFiles.value.length) return null
|
||||||
@@ -116,10 +108,7 @@ const configsError = computed(() => {
|
|||||||
return cause instanceof Error ? cause.message : ''
|
return cause instanceof Error ? cause.message : ''
|
||||||
})
|
})
|
||||||
const initLoading = computed(() =>
|
const initLoading = computed(() =>
|
||||||
loading.value
|
loading.value,
|
||||||
|| activityQuery.isPending.value
|
|
||||||
|| summaryQuery.isPending.value
|
|
||||||
|| (canConfigure.value && filesQuery.isPending.value),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const fallbackName = computed(() => {
|
const fallbackName = computed(() => {
|
||||||
@@ -185,6 +174,23 @@ function scheduleActivityReload() {
|
|||||||
}, 250)
|
}, 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 {
|
function formatSummaryTimestamp(value?: string | null): string {
|
||||||
if (!value) return 'No timestamp'
|
if (!value) return 'No timestamp'
|
||||||
const d = new Date(value)
|
const d = new Date(value)
|
||||||
@@ -316,12 +322,14 @@ async function saveFile() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({}))
|
const err = await response.json().catch(() => ({}))
|
||||||
const problem = err as {
|
const problem = err as {
|
||||||
|
detail?: string
|
||||||
error?: string
|
error?: string
|
||||||
message?: string
|
message?: string
|
||||||
errors?: Record<string, string[]>
|
errors?: Record<string, string[]>
|
||||||
currentHash?: string
|
currentHash?: string
|
||||||
}
|
}
|
||||||
const detail = problem.message
|
const detail = problem.detail
|
||||||
|
|| problem.message
|
||||||
|| problem.error
|
|| problem.error
|
||||||
|| Object.values(problem.errors ?? {}).flat().join(' ')
|
|| Object.values(problem.errors ?? {}).flat().join(' ')
|
||||||
|| 'Failed to save file'
|
|| 'Failed to save file'
|
||||||
@@ -401,12 +409,30 @@ onUnmounted(() => {
|
|||||||
Zurück zu Agents
|
Zurück zu Agents
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div v-if="initLoading" class="status-message">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="20" class="spin" />
|
v-if="initLoading && !agent"
|
||||||
Loading agent data...
|
state="loading"
|
||||||
</div>
|
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>
|
<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 -->
|
<!-- Agent header -->
|
||||||
<div class="agent-header">
|
<div class="agent-header">
|
||||||
<div class="agent-avatar" :class="agentId">
|
<div class="agent-avatar" :class="agentId">
|
||||||
@@ -426,12 +452,6 @@ onUnmounted(() => {
|
|||||||
{{ formatLastSeen(agent.lastSeen) }}
|
{{ formatLastSeen(agent.lastSeen) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="error && !agent" class="agent-status-row">
|
|
||||||
<span class="status-label muted">
|
|
||||||
<AlertCircle :size="11" />
|
|
||||||
{{ error }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -455,15 +475,21 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="summaryLoading && !summary" class="status-message compact">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="16" class="spin" />
|
v-if="summaryLoading && !summary"
|
||||||
Loading summaries...
|
state="loading"
|
||||||
</div>
|
title="Zusammenfassungen werden geladen"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="summaryError && !summary" class="status-message compact error">
|
<AsyncStatePanel
|
||||||
<AlertCircle :size="16" />
|
v-else-if="summaryQuery.error.value && !summary"
|
||||||
{{ summaryError }}
|
state="error"
|
||||||
</div>
|
title="Zusammenfassungen nicht verfügbar"
|
||||||
|
:problem="summaryQuery.error.value"
|
||||||
|
compact
|
||||||
|
@action="retrySummary"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="summary" class="summary-row">
|
<div v-else-if="summary" class="summary-row">
|
||||||
<div class="summary-card">
|
<div class="summary-card">
|
||||||
@@ -478,30 +504,61 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="status-message compact">
|
<AsyncStatePanel
|
||||||
No summary available.
|
v-else
|
||||||
</div>
|
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">
|
<AsyncStatePanel
|
||||||
<AlertCircle :size="14" />
|
v-if="summaryQuery.error.value && summary"
|
||||||
{{ summaryError }}
|
state="stale"
|
||||||
</div>
|
title="Zusammenfassung möglicherweise veraltet"
|
||||||
|
:problem="summaryQuery.error.value"
|
||||||
|
action-label="Aktualisieren"
|
||||||
|
compact
|
||||||
|
@action="retrySummary"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="liveUnavailable" class="status-message compact">
|
<AsyncStatePanel
|
||||||
Live stream reconnecting…
|
v-if="liveUnavailable"
|
||||||
</div>
|
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">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="16" class="spin" />
|
v-if="activityLoading && !activityItems.length"
|
||||||
Loading activity...
|
state="loading"
|
||||||
</div>
|
title="Aktivität wird geladen"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="activityError" class="status-message compact error">
|
<AsyncStatePanel
|
||||||
<AlertCircle :size="16" />
|
v-else-if="activityQuery.error.value && !activityItems.length"
|
||||||
{{ activityError }}
|
state="error"
|
||||||
</div>
|
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
|
<article
|
||||||
v-for="item in activityItems"
|
v-for="item in activityItems"
|
||||||
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
|
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
|
||||||
@@ -514,25 +571,48 @@ onUnmounted(() => {
|
|||||||
<p>{{ item.message }}</p>
|
<p>{{ item.message }}</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div v-else class="status-message compact">
|
<AsyncStatePanel
|
||||||
No recent activity.
|
v-else
|
||||||
</div>
|
state="empty"
|
||||||
|
title="Keine aktuelle Aktivität"
|
||||||
|
message="Für diesen Agenten liegen noch keine bestätigten Events vor."
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Config section -->
|
<!-- Config section -->
|
||||||
<div class="config-section">
|
<div class="config-section">
|
||||||
<div v-if="configsLoading" class="status-message">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="16" class="spin" />
|
v-if="configsLoading && !configFiles.length"
|
||||||
Loading config files...
|
state="loading"
|
||||||
</div>
|
title="Agent-Dateien werden geladen"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="configsError" class="status-message error">
|
<AsyncStatePanel
|
||||||
<AlertCircle :size="16" />
|
v-else-if="configsError && !configFiles.length"
|
||||||
{{ configsError }}
|
state="error"
|
||||||
</div>
|
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>
|
<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
|
<ConfigTabs
|
||||||
:tabs="configFiles.map(file => file.name)"
|
:tabs="configFiles.map(file => file.name)"
|
||||||
:active-tab="activeTab"
|
:active-tab="activeTab"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type OperationResultDto,
|
type OperationResultDto,
|
||||||
} from '../api/contracts'
|
} from '../api/contracts'
|
||||||
import OperationResultCard from '../components/mission-control/OperationResultCard.vue'
|
import OperationResultCard from '../components/mission-control/OperationResultCard.vue'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
type ActionMode = 'approve' | 'reject' | 'retry'
|
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>
|
<span>Agent-Vorschläge und Provisionierungsaktionen sind ausschließlich für Owner sichtbar.</span>
|
||||||
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
|
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="proposalQuery.isPending.value" class="nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<LoaderCircle class="spin" :size="18" aria-hidden="true" />
|
v-else-if="proposalQuery.isPending.value"
|
||||||
Vorschlag wird geladen…
|
state="loading"
|
||||||
</div>
|
title="Agent-Vorschlag wird geladen"
|
||||||
<div v-else-if="proposalQuery.isError.value" class="nexus-state nexus-state--error" role="alert">
|
/>
|
||||||
<strong>Vorschlag nicht verfügbar</strong>
|
<AsyncStatePanel
|
||||||
<span>{{ proposalQuery.error.value?.message || 'Der Proposal-Vertrag konnte nicht gelesen werden.' }}</span>
|
v-else-if="proposalQuery.isError.value"
|
||||||
<RouterLink class="nexus-button" to="/agents">Zur Agentenübersicht</RouterLink>
|
state="error"
|
||||||
</div>
|
title="Agent-Vorschlag nicht verfügbar"
|
||||||
|
:problem="proposalQuery.error.value"
|
||||||
|
@action="proposalQuery.refetch()"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-else-if="proposal">
|
<template v-else-if="proposal">
|
||||||
<section class="status-hero nexus-panel" :class="`status-hero--${statusMeta.tone}`">
|
<section class="status-hero nexus-panel" :class="`status-hero--${statusMeta.tone}`">
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from '../api/openclawRuntime'
|
} from '../api/openclawRuntime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type { OpenClawAgent } from '../types/openclaw'
|
import type { OpenClawAgent } from '../types/openclaw'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -254,8 +255,20 @@ function proposalStatus(proposal: AgentProposalDto) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="load-error nexus-state nexus-state--loading" role="status">Lade Gateway-Status...</div>
|
<AsyncStatePanel
|
||||||
<div v-else-if="error" class="load-error nexus-state nexus-state--error" role="alert">{{ error }}</div>
|
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">
|
<div v-if="gatewayWarning" class="gateway-warning nexus-state nexus-status--warning" role="status">
|
||||||
{{ gatewayWarning }}
|
{{ gatewayWarning }}
|
||||||
</div>
|
</div>
|
||||||
@@ -368,10 +381,12 @@ function proposalStatus(proposal: AgentProposalDto) {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loading" class="empty-state nexus-state nexus-state--empty">
|
<AsyncStatePanel
|
||||||
<h3>Keine Agenten sichtbar</h3>
|
v-else-if="!loading"
|
||||||
<p>Mission Control hat aktuell keine Agenten aus dem Backend erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration.</p>
|
state="empty"
|
||||||
</div>
|
title="Keine Agenten sichtbar"
|
||||||
|
message="Mission Control hat aktuell keine Agenten erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
useOpenClawCronRuns,
|
useOpenClawCronRuns,
|
||||||
type CreateOpenClawCronJobRequest,
|
type CreateOpenClawCronJobRequest,
|
||||||
} from '../api/openClawCron'
|
} from '../api/openClawCron'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import type {
|
import type {
|
||||||
@@ -870,28 +871,27 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="cronJobsLoading && !collection" class="nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
v-if="cronJobsLoading && !collection"
|
||||||
<p>Loading OpenClaw schedules…</p>
|
state="loading"
|
||||||
</div>
|
title="OpenClaw-Zeitpläne werden geladen"
|
||||||
<div v-else-if="cronJobsError && !collection" class="nexus-state nexus-state--error" role="alert">
|
/>
|
||||||
<AlertCircle :size="18" aria-hidden="true" />
|
<AsyncStatePanel
|
||||||
<h2>Scheduler unavailable</h2>
|
v-else-if="cronJobsError && !collection"
|
||||||
<p>{{ cronJobsError }}</p>
|
state="error"
|
||||||
<RouterLink class="nexus-button" to="/settings">OpenClaw diagnostics</RouterLink>
|
title="Scheduler nicht verfügbar"
|
||||||
</div>
|
:problem="jobsQuery.error.value"
|
||||||
<div
|
action-label="OpenClaw-Diagnose öffnen"
|
||||||
|
@action="router.push('/settings')"
|
||||||
|
/>
|
||||||
|
<AsyncStatePanel
|
||||||
v-else-if="collection && collection.state !== 'ready'"
|
v-else-if="collection && collection.state !== 'ready'"
|
||||||
class="nexus-state"
|
:state="['disconnected', 'error', 'failed'].includes(collection.state) ? 'offline' : 'partial'"
|
||||||
:class="{ 'nexus-state--error': ['disconnected', 'error', 'failed'].includes(collection.state) }"
|
:title="`Scheduler: ${collection.state}`"
|
||||||
role="status"
|
:message="collection.recovery || collection.message || 'OpenClaw hat keinen vollständigen Cron-Katalog geliefert.'"
|
||||||
>
|
action-label="Verbindung prüfen"
|
||||||
<AlertCircle :size="18" aria-hidden="true" />
|
@action="router.push('/settings')"
|
||||||
<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>
|
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section class="calendar-layout">
|
<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 TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
||||||
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
||||||
import { useFlowBoardState } from '../../composables/useFlowBoardState'
|
import { useFlowBoardState } from '../../composables/useFlowBoardState'
|
||||||
|
import AsyncStatePanel from '../../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
/* ── Stores ──────────────────────────────────────── */
|
/* ── Stores ──────────────────────────────────────── */
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
@@ -141,12 +142,6 @@ const taskItems = computed(() => activeCards.value.map(mapTask))
|
|||||||
const blockedTasks = computed(() =>
|
const blockedTasks = computed(() =>
|
||||||
activeCards.value.filter(task => task.state.toLowerCase() === 'blocked'),
|
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() {
|
function handleBlockerClick() {
|
||||||
const blockedTask = blockedTasks.value[0]
|
const blockedTask = blockedTasks.value[0]
|
||||||
if (!blockedTask) return
|
if (!blockedTask) return
|
||||||
@@ -181,7 +176,22 @@ function blockerCount() {
|
|||||||
@blocker-click="handleBlockerClick"
|
@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
|
<FlowCanvas
|
||||||
|
v-else
|
||||||
:agents="agentNodes"
|
:agents="agentNodes"
|
||||||
:positions="agentPositions"
|
:positions="agentPositions"
|
||||||
:entering-ids="enteringIds"
|
:entering-ids="enteringIds"
|
||||||
@@ -193,7 +203,8 @@ function blockerCount() {
|
|||||||
<TaskStrip
|
<TaskStrip
|
||||||
:tasks="taskItems"
|
:tasks="taskItems"
|
||||||
:loading="taskBoard.query.isLoading.value"
|
:loading="taskBoard.query.isLoading.value"
|
||||||
:error="taskError"
|
:error="taskBoard.query.error.value"
|
||||||
|
@retry="taskBoard.query.refetch()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,6 +253,11 @@ function blockerCount() {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.orchestration-state {
|
||||||
|
flex: 1;
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
.board-body {
|
.board-body {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
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 { RouterLink } from 'vue-router'
|
||||||
import { useDocQuery, useDocsQuery } from '../api/knowledge'
|
import { useDocQuery, useDocsQuery } from '../api/knowledge'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const docsQuery = useDocsQuery()
|
const docsQuery = useDocsQuery()
|
||||||
@@ -22,10 +23,8 @@ const selectedDocInfo = computed(() =>
|
|||||||
docs.value.find(doc => doc.path === selectedDocPath.value) ?? null,
|
docs.value.find(doc => doc.path === selectedDocPath.value) ?? null,
|
||||||
)
|
)
|
||||||
const contentLoading = computed(() => docQuery.isFetching.value)
|
const contentLoading = computed(() => docQuery.isFetching.value)
|
||||||
const error = computed(() => {
|
const listProblem = computed(() => docsQuery.error.value)
|
||||||
const cause = docQuery.error.value ?? docsQuery.error.value
|
const contentProblem = computed(() => docQuery.error.value)
|
||||||
return cause instanceof Error ? cause.message : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const categories = ['phases', 'skills', 'workspace', 'nexus', 'nexus-phases']
|
const categories = ['phases', 'skills', 'workspace', 'nexus', 'nexus-phases']
|
||||||
|
|
||||||
@@ -50,6 +49,14 @@ function goBack() {
|
|||||||
selectedDocPath.value = ''
|
selectedDocPath.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryList() {
|
||||||
|
void docsQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryContent() {
|
||||||
|
void docQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
@@ -98,12 +105,30 @@ function formatSize(bytes: number | string): string {
|
|||||||
<div class="memory-layout">
|
<div class="memory-layout">
|
||||||
<!-- Left column: document list -->
|
<!-- Left column: document list -->
|
||||||
<aside class="memory-sidebar">
|
<aside class="memory-sidebar">
|
||||||
<div v-if="loading" class="memory-status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="16" class="spin" />
|
v-if="loading && !docs.length"
|
||||||
Loading documents...
|
state="loading"
|
||||||
</div>
|
title="Dokumente werden geladen"
|
||||||
<div v-else-if="error" class="memory-status error">{{ error }}</div>
|
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">
|
<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>
|
<div class="memory-list-header">{{ filteredDocs.length }} documents</div>
|
||||||
<button
|
<button
|
||||||
v-for="doc in filteredDocs"
|
v-for="doc in filteredDocs"
|
||||||
@@ -129,18 +154,39 @@ function formatSize(bytes: number | string): string {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Right column: content -->
|
<!-- Right column: content -->
|
||||||
<main class="memory-content">
|
<main class="memory-content">
|
||||||
<template v-if="contentLoading">
|
<AsyncStatePanel
|
||||||
<div class="memory-status">
|
v-if="contentLoading && !selectedDoc"
|
||||||
<Loader2 :size="20" class="spin" />
|
state="loading"
|
||||||
Loading content...
|
title="Dokument wird geladen"
|
||||||
</div>
|
/>
|
||||||
</template>
|
<AsyncStatePanel
|
||||||
|
v-else-if="contentProblem && !selectedDoc"
|
||||||
|
state="error"
|
||||||
|
title="Dokument nicht verfügbar"
|
||||||
|
:problem="contentProblem"
|
||||||
|
@action="retryContent"
|
||||||
|
/>
|
||||||
<template v-else-if="selectedDoc">
|
<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">
|
<header class="memory-content-header">
|
||||||
<button type="button" class="memory-back-btn" @click="goBack">
|
<button type="button" class="memory-back-btn" @click="goBack">
|
||||||
<ArrowLeft :size="14" />
|
<ArrowLeft :size="14" />
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
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 { useIncidentQuery, useIncidentsQuery } from '../api/incidents'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const overviewQuery = useOpenClawOverviewQuery()
|
const overviewQuery = useOpenClawOverviewQuery()
|
||||||
const incidentsQuery = useIncidentsQuery()
|
const incidentsQuery = useIncidentsQuery()
|
||||||
@@ -21,10 +22,8 @@ const selectedIncidentSummary = computed(() =>
|
|||||||
incidents.value.find(incident => incident.name === selectedIncidentName.value) ?? null,
|
incidents.value.find(incident => incident.name === selectedIncidentName.value) ?? null,
|
||||||
)
|
)
|
||||||
const contentLoading = computed(() => incidentQuery.isFetching.value)
|
const contentLoading = computed(() => incidentQuery.isFetching.value)
|
||||||
const error = computed(() => {
|
const listProblem = computed(() => incidentsQuery.error.value)
|
||||||
const cause = incidentQuery.error.value ?? incidentsQuery.error.value
|
const contentProblem = computed(() => incidentQuery.error.value)
|
||||||
return cause instanceof Error ? cause.message : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sorted incidents (newest first by date)
|
// Sorted incidents (newest first by date)
|
||||||
const sortedIncidents = computed(() => {
|
const sortedIncidents = computed(() => {
|
||||||
@@ -68,6 +67,18 @@ function goBack() {
|
|||||||
selectedIncidentName.value = ''
|
selectedIncidentName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryList() {
|
||||||
|
void incidentsQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryContent() {
|
||||||
|
void incidentQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryRuntime() {
|
||||||
|
void overviewQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
@@ -118,19 +129,44 @@ function formatSize(bytes: number | string): string {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<div class="incident-layout">
|
||||||
<!-- Left column: incident list -->
|
<!-- Left column: incident list -->
|
||||||
<aside class="incident-sidebar">
|
<aside class="incident-sidebar">
|
||||||
<template v-if="loading">
|
<AsyncStatePanel
|
||||||
<div class="incident-status">
|
v-if="loading && !incidents.length"
|
||||||
<Loader2 :size="16" class="spin" />
|
state="loading"
|
||||||
Loading incidents...
|
title="Incidents werden geladen"
|
||||||
</div>
|
compact
|
||||||
</template>
|
/>
|
||||||
<template v-else-if="error">
|
<AsyncStatePanel
|
||||||
<div class="incident-status error">{{ error }}</div>
|
v-else-if="listProblem && !incidents.length"
|
||||||
</template>
|
state="error"
|
||||||
|
title="Incident-Berichte nicht verfügbar"
|
||||||
|
:problem="listProblem"
|
||||||
|
compact
|
||||||
|
@action="retryList"
|
||||||
|
/>
|
||||||
<template v-else-if="sortedIncidents.length">
|
<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>
|
<div class="incident-list-header">{{ sortedIncidents.length }} reports</div>
|
||||||
<button
|
<button
|
||||||
v-for="inc in sortedIncidents"
|
v-for="inc in sortedIncidents"
|
||||||
@@ -154,18 +190,39 @@ function formatSize(bytes: number | string): string {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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>
|
</aside>
|
||||||
|
|
||||||
<!-- Right column: detail view -->
|
<!-- Right column: detail view -->
|
||||||
<main class="incident-content">
|
<main class="incident-content">
|
||||||
<template v-if="contentLoading">
|
<AsyncStatePanel
|
||||||
<div class="incident-status">
|
v-if="contentLoading && !selectedIncident"
|
||||||
<Loader2 :size="20" class="spin" />
|
state="loading"
|
||||||
Loading incident...
|
title="Incident wird geladen"
|
||||||
</div>
|
/>
|
||||||
</template>
|
<AsyncStatePanel
|
||||||
|
v-else-if="contentProblem && !selectedIncident"
|
||||||
|
state="error"
|
||||||
|
title="Incident nicht verfügbar"
|
||||||
|
:problem="contentProblem"
|
||||||
|
@action="retryContent"
|
||||||
|
/>
|
||||||
<template v-else-if="selectedIncident">
|
<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">
|
<header class="incident-content-header">
|
||||||
<button type="button" class="incident-back-btn" @click="goBack">
|
<button type="button" class="incident-back-btn" @click="goBack">
|
||||||
<ArrowLeft :size="14" />
|
<ArrowLeft :size="14" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
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 { RouterLink } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
useMemoryFileQuery,
|
useMemoryFileQuery,
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useMemorySearchQuery,
|
useMemorySearchQuery,
|
||||||
} from '../api/knowledge'
|
} from '../api/knowledge'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const memoriesQuery = useMemoryFilesQuery()
|
const memoriesQuery = useMemoryFilesQuery()
|
||||||
@@ -27,10 +28,9 @@ const selectedMemory = computed(() =>
|
|||||||
selectedMemoryName.value ? memoryQuery.data.value ?? null : null,
|
selectedMemoryName.value ? memoryQuery.data.value ?? null : null,
|
||||||
)
|
)
|
||||||
const contentLoading = computed(() => memoryQuery.isFetching.value)
|
const contentLoading = computed(() => memoryQuery.isFetching.value)
|
||||||
const error = computed(() => {
|
const listProblem = computed(() => memoriesQuery.error.value)
|
||||||
const cause = memoryQuery.error.value ?? memoriesQuery.error.value
|
const contentProblem = computed(() => memoryQuery.error.value)
|
||||||
return cause instanceof Error ? cause.message : ''
|
const searchProblem = computed(() => searchResultsQuery.error.value)
|
||||||
})
|
|
||||||
|
|
||||||
// Sorted memories (newest first)
|
// Sorted memories (newest first)
|
||||||
const sortedMemories = computed(() => {
|
const sortedMemories = computed(() => {
|
||||||
@@ -63,6 +63,18 @@ function goBack() {
|
|||||||
selectedMemoryName.value = ''
|
selectedMemoryName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function retryList() {
|
||||||
|
void memoriesQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrySearch() {
|
||||||
|
void searchResultsQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryContent() {
|
||||||
|
void memoryQuery.refetch()
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||||
year: 'numeric', month: 'short', day: 'numeric',
|
year: 'numeric', month: 'short', day: 'numeric',
|
||||||
@@ -109,11 +121,30 @@ onUnmounted(() => {
|
|||||||
<aside class="memory-sidebar">
|
<aside class="memory-sidebar">
|
||||||
<!-- Search results -->
|
<!-- Search results -->
|
||||||
<template v-if="searchQuery.trim().length >= 2">
|
<template v-if="searchQuery.trim().length >= 2">
|
||||||
<div v-if="searchLoading" class="memory-status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="16" class="spin" />
|
v-if="searchLoading && !searchResults.length"
|
||||||
Searching...
|
state="loading"
|
||||||
</div>
|
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">
|
<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>
|
<div class="memory-list-header">Search results ({{ searchResults.length }})</div>
|
||||||
<button
|
<button
|
||||||
v-for="result in searchResults"
|
v-for="result in searchResults"
|
||||||
@@ -130,17 +161,41 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
|
|
||||||
<!-- File list (default) -->
|
<!-- File list (default) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="loading" class="memory-status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="16" class="spin" />
|
v-if="loading && !memories.length"
|
||||||
Loading memory files...
|
state="loading"
|
||||||
</div>
|
title="Memory-Dateien werden geladen"
|
||||||
<div v-else-if="error" class="memory-status error">{{ error }}</div>
|
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">
|
<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>
|
<div class="memory-list-header">{{ sortedMemories.length }} files</div>
|
||||||
<button
|
<button
|
||||||
v-for="mem in sortedMemories"
|
v-for="mem in sortedMemories"
|
||||||
@@ -161,19 +216,40 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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>
|
</template>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Right column: content -->
|
<!-- Right column: content -->
|
||||||
<main class="memory-content">
|
<main class="memory-content">
|
||||||
<template v-if="contentLoading">
|
<AsyncStatePanel
|
||||||
<div class="memory-status">
|
v-if="contentLoading && !selectedMemory"
|
||||||
<Loader2 :size="20" class="spin" />
|
state="loading"
|
||||||
Loading content...
|
title="Memory-Inhalt wird geladen"
|
||||||
</div>
|
/>
|
||||||
</template>
|
<AsyncStatePanel
|
||||||
|
v-else-if="contentProblem && !selectedMemory"
|
||||||
|
state="error"
|
||||||
|
title="Memory-Inhalt nicht verfügbar"
|
||||||
|
:problem="contentProblem"
|
||||||
|
@action="retryContent"
|
||||||
|
/>
|
||||||
<template v-else-if="selectedMemory">
|
<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">
|
<header class="memory-content-header">
|
||||||
<button type="button" class="memory-back-btn" @click="goBack">
|
<button type="button" class="memory-back-btn" @click="goBack">
|
||||||
<ArrowLeft :size="14" />
|
<ArrowLeft :size="14" />
|
||||||
|
|||||||
@@ -14,13 +14,15 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
refreshOpenClawModelAuthStatus,
|
refreshOpenClawModelAuthStatus,
|
||||||
useOpenClawModelAuthQuery,
|
useOpenClawModelAuthQuery,
|
||||||
type OpenClawModelAuthProviderDto,
|
type OpenClawModelAuthProviderDto,
|
||||||
} from '../api/openClawModels'
|
} from '../api/openClawModels'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const modelAuthQuery = useOpenClawModelAuthQuery()
|
const modelAuthQuery = useOpenClawModelAuthQuery()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const statusFilter = ref('all')
|
const statusFilter = ref('all')
|
||||||
@@ -250,54 +252,38 @@ onUnmounted(() => {
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div
|
<AsyncStatePanel
|
||||||
v-if="modelAuthQuery.isPending.value && !collection"
|
v-if="modelAuthQuery.isPending.value && !collection"
|
||||||
class="nexus-state nexus-state--loading"
|
state="loading"
|
||||||
role="status"
|
title="Provider-Status wird geladen"
|
||||||
>
|
message="Nexus liest den bereinigten Authentifizierungsstatus aus OpenClaw."
|
||||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
/>
|
||||||
<p>Loading sanitized provider health from OpenClaw…</p>
|
<AsyncStatePanel
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="modelAuthError && !collection"
|
v-else-if="modelAuthError && !collection"
|
||||||
class="nexus-state nexus-state--error"
|
state="error"
|
||||||
role="alert"
|
title="Provider-Status nicht verfügbar"
|
||||||
>
|
:problem="modelAuthQuery.error.value"
|
||||||
<CircleAlert :size="18" aria-hidden="true" />
|
@action="refresh"
|
||||||
<h2>Provider health unavailable</h2>
|
/>
|
||||||
<p>{{ modelAuthError }}</p>
|
<AsyncStatePanel
|
||||||
<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
|
|
||||||
v-else-if="collection && collection.state !== 'ready'"
|
v-else-if="collection && collection.state !== 'ready'"
|
||||||
class="nexus-state"
|
:state="['error', 'disconnected', 'forbidden'].includes(collection.state) ? 'offline' : 'partial'"
|
||||||
:class="{
|
:title="collection.state === 'unsupported' ? 'Provider-Status nicht unterstützt' : `Provider-Status: ${collection.state}`"
|
||||||
'nexus-state--error':
|
:message="collection.recovery || collection.message || 'OpenClaw hat keinen verwendbaren Provider-Snapshot geliefert.'"
|
||||||
collection.state === 'error'
|
action-label="OpenClaw-Setup öffnen"
|
||||||
|| collection.state === 'disconnected'
|
@action="router.push('/settings')"
|
||||||
|| 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>
|
|
||||||
|
|
||||||
<template v-else-if="collection">
|
<template v-else-if="collection">
|
||||||
<div v-if="modelAuthError" class="refresh-warning nexus-state nexus-state--error" role="alert">
|
<AsyncStatePanel
|
||||||
<CircleAlert :size="16" aria-hidden="true" />
|
v-if="modelAuthError"
|
||||||
<p>Refresh failed. The last successful snapshot remains visible. {{ modelAuthError }}</p>
|
state="stale"
|
||||||
</div>
|
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">
|
<section class="model-toolbar nexus-panel" aria-label="Provider health filters">
|
||||||
<label class="model-search">
|
<label class="model-search">
|
||||||
@@ -359,14 +345,12 @@ onUnmounted(() => {
|
|||||||
<span v-if="provider.usage?.plan" class="model-plan">{{ provider.usage.plan }}</span>
|
<span v-if="provider.usage?.plan" class="model-plan">{{ provider.usage.plan }}</span>
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<div v-else class="nexus-state nexus-state--empty">
|
<AsyncStatePanel
|
||||||
<ShieldCheck :size="18" aria-hidden="true" />
|
v-else
|
||||||
<h2>{{ providers.length ? 'No matching providers' : 'No provider health reported' }}</h2>
|
state="empty"
|
||||||
<p v-if="providers.length">Adjust the provider search or status filter.</p>
|
:title="providers.length ? 'Keine passenden Provider' : 'Kein Provider-Status gemeldet'"
|
||||||
<p v-else>
|
:message="providers.length ? 'Passe Suche oder Statusfilter an.' : 'OpenClaw hat keine konfigurierten Auth-Profile gemeldet.'"
|
||||||
OpenClaw returned no configured authentication profiles or API-key sources.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -142,18 +143,24 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="notificationQuery.isLoading.value && !sortedNotifications.length" class="empty-state nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="24" class="spin" />
|
v-if="notificationQuery.isLoading.value && !sortedNotifications.length"
|
||||||
<p>Benachrichtigungen werden geladen…</p>
|
state="loading"
|
||||||
</div>
|
title="Benachrichtigungen werden geladen"
|
||||||
<div v-else-if="notificationQuery.error.value && !sortedNotifications.length" class="empty-state nexus-state nexus-state--error" role="alert">
|
/>
|
||||||
<BellOff :size="28" />
|
<AsyncStatePanel
|
||||||
<p>{{ notificationQuery.error.value.message }}</p>
|
v-else-if="notificationQuery.error.value && !sortedNotifications.length"
|
||||||
</div>
|
state="error"
|
||||||
<div v-else-if="sortedNotifications.length === 0" class="empty-state nexus-state nexus-state--empty">
|
title="Benachrichtigungen nicht verfügbar"
|
||||||
<BellOff :size="48" />
|
:problem="notificationQuery.error.value"
|
||||||
<p>Keine Benachrichtigungen</p>
|
@action="notificationQuery.refetch()"
|
||||||
</div>
|
/>
|
||||||
|
<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 v-else class="notification-list">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useProject, useProjectTasks } from '../api/projects'
|
|||||||
import { useOpenClawRuns } from '../api/openClawRuns'
|
import { useOpenClawRuns } from '../api/openClawRuns'
|
||||||
import type { EntityRefDto } from '../api/contracts'
|
import type { EntityRefDto } from '../api/contracts'
|
||||||
import EntityLink from '../components/mission-control/EntityLink.vue'
|
import EntityLink from '../components/mission-control/EntityLink.vue'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -115,8 +116,19 @@ function getTaskStateIcon(state: string) {
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">Loading project...</div>
|
<AsyncStatePanel
|
||||||
<div v-else-if="loadError" class="error-state">{{ loadError }}</div>
|
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">
|
<template v-else-if="project">
|
||||||
<div class="project-detail-card">
|
<div class="project-detail-card">
|
||||||
<div class="project-detail-top">
|
<div class="project-detail-top">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Boxes, CalendarClock, CircleAlert, Loader2, Plus, RefreshCw } from '@lu
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useProjects } from '../api/projects'
|
import { useProjects } from '../api/projects'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const projects = useProjects()
|
const projects = useProjects()
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
@@ -94,25 +95,24 @@ async function submit() {
|
|||||||
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section v-if="projects.query.isLoading.value" class="nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="22" class="spin" aria-hidden="true" />
|
v-if="projects.query.isLoading.value"
|
||||||
<p>Projekte werden geladen…</p>
|
state="loading"
|
||||||
</section>
|
title="Projekte werden geladen"
|
||||||
<section v-else-if="projects.query.isError.value" class="nexus-state nexus-state--error" role="alert">
|
/>
|
||||||
<CircleAlert :size="22" aria-hidden="true" />
|
<AsyncStatePanel
|
||||||
<div>
|
v-else-if="projects.query.isError.value"
|
||||||
<h2>Projektliste nicht verfügbar</h2>
|
state="error"
|
||||||
<p>{{ projects.query.error.value instanceof Error ? projects.query.error.value.message : 'Unbekannter Fehler' }}</p>
|
title="Projektliste nicht verfügbar"
|
||||||
</div>
|
:problem="projects.query.error.value"
|
||||||
<button type="button" class="nexus-button" @click="projects.query.refetch()">Erneut versuchen</button>
|
@action="projects.query.refetch()"
|
||||||
</section>
|
/>
|
||||||
<section v-else-if="!items.length" class="nexus-state nexus-state--empty">
|
<AsyncStatePanel
|
||||||
<Boxes :size="28" aria-hidden="true" />
|
v-else-if="!items.length"
|
||||||
<div>
|
state="empty"
|
||||||
<h2>Noch keine Projekte</h2>
|
title="Noch keine Projekte"
|
||||||
<p>Lege den ersten Mission-Scope über das Formular an.</p>
|
message="Lege den ersten Mission-Scope über das Formular an."
|
||||||
</div>
|
/>
|
||||||
</section>
|
|
||||||
<section v-else class="project-grid" aria-label="Projekte">
|
<section v-else class="project-grid" aria-label="Projekte">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="project in items"
|
v-for="project in items"
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
|||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useMissionControlUiStore } from '../stores/missionControlUi'
|
import { useMissionControlUiStore } from '../stores/missionControlUi'
|
||||||
import { useOpenClawStore } from '../stores/openclaw'
|
import { useOpenClawStore } from '../stores/openclaw'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
import type {
|
import type {
|
||||||
OpenClawActivity,
|
OpenClawActivity,
|
||||||
OpenClawApproval,
|
OpenClawApproval,
|
||||||
@@ -423,28 +424,29 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="overviewQuery.isPending.value && !overview" class="nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="20" class="spin" />
|
v-if="overviewQuery.isPending.value && !overview"
|
||||||
<div><h2>OpenClaw control plane wird geladen</h2><p>Gateway handshake and capability negotiation are in progress.</p></div>
|
state="loading"
|
||||||
</div>
|
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">
|
<AsyncStatePanel
|
||||||
<AlertTriangle :size="20" />
|
v-else-if="overviewError && !overview"
|
||||||
<div><h2>Run Control ist nicht erreichbar</h2><p>{{ overviewError }}</p></div>
|
state="error"
|
||||||
</div>
|
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">
|
<AsyncStatePanel
|
||||||
<Network :size="24" />
|
v-else-if="connection && !connection.connected"
|
||||||
<div>
|
state="offline"
|
||||||
<h2>OpenClaw Gateway ist {{ connection.state }}</h2>
|
:title="`OpenClaw Gateway ist ${connection.state}`"
|
||||||
<p>{{ connection.recovery || connection.message || 'Gateway configuration and credentials must be checked.' }}</p>
|
:message="connection.recovery || connection.message || 'Gateway-Konfiguration und Zugang müssen geprüft werden.'"
|
||||||
<code v-if="connection.pairingRequired">
|
action-label="Integration prüfen"
|
||||||
Pairing request: {{ connection.pairingRequestId || 'not reported' }}
|
@action="router.push('/settings')"
|
||||||
</code>
|
/>
|
||||||
<code>{{ connection.endpoint }}</code>
|
|
||||||
</div>
|
|
||||||
<RouterLink class="nexus-button" to="/settings">Integration prüfen</RouterLink>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<template v-else-if="overview">
|
<template v-else-if="overview">
|
||||||
<section class="run-strip" aria-label="OpenClaw control-plane status">
|
<section class="run-strip" aria-label="OpenClaw control-plane status">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from '../api/openClawRuns'
|
} from '../api/openClawRuns'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useOpenClawStore } from '../stores/openclaw'
|
import { useOpenClawStore } from '../stores/openclaw'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
type RunAction = 'stop' | 'retry' | 'resume'
|
type RunAction = 'stop' | 'retry' | 'resume'
|
||||||
|
|
||||||
@@ -202,16 +203,21 @@ async function executeAction() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div v-if="detailLoading && !run" class="nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="20" class="spin" />
|
v-if="detailLoading && !run"
|
||||||
<div><h2>Loading durable run</h2><p>Nexus is reconciling local transitions with OpenClaw history.</p></div>
|
state="loading"
|
||||||
</div>
|
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">
|
<AsyncStatePanel
|
||||||
<AlertTriangle :size="20" />
|
v-else-if="detailError && !run"
|
||||||
<div><h2>Run could not be loaded</h2><p>{{ detailError }}</p></div>
|
state="error"
|
||||||
<RouterLink class="nexus-button" to="/runs">Return to Run Control</RouterLink>
|
title="Run konnte nicht geladen werden"
|
||||||
</div>
|
:message="detailError"
|
||||||
|
action-label="Zur Run Control"
|
||||||
|
@action="router.push('/runs')"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-else-if="run">
|
<template v-else-if="run">
|
||||||
<section v-if="run.sequenceGapDetected" class="gap-warning" role="alert">
|
<section v-if="run.sequenceGapDetected" class="gap-warning" role="alert">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
useOpenClawOverviewQuery,
|
useOpenClawOverviewQuery,
|
||||||
} from '../api/openclawRuntime'
|
} from '../api/openclawRuntime'
|
||||||
import { useSecurityStatusQuery } from '../api/security'
|
import { useSecurityStatusQuery } from '../api/security'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const statusQuery = useSecurityStatusQuery()
|
const statusQuery = useSecurityStatusQuery()
|
||||||
const status = computed(() => statusQuery.data.value ?? null)
|
const status = computed(() => statusQuery.data.value ?? null)
|
||||||
@@ -165,15 +166,18 @@ async function refresh() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="loading && !status" class="nexus-state nexus-state--loading" role="status">
|
<AsyncStatePanel
|
||||||
<Loader2 :size="18" class="spin" aria-hidden="true" />
|
v-if="loading && !status"
|
||||||
<p>Loading Nexus security configuration…</p>
|
state="loading"
|
||||||
</div>
|
title="Security-Konfiguration wird geladen"
|
||||||
<div v-else-if="error && !status" class="nexus-state nexus-state--error" role="alert">
|
/>
|
||||||
<CircleAlert :size="18" aria-hidden="true" />
|
<AsyncStatePanel
|
||||||
<h2>Nexus security status unavailable</h2>
|
v-else-if="error && !status"
|
||||||
<p>{{ error }}</p>
|
state="error"
|
||||||
</div>
|
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">
|
<section v-else-if="status" class="security-grid" aria-label="Nexus security controls">
|
||||||
<article class="security-card nexus-card">
|
<article class="security-card nexus-card">
|
||||||
|
|||||||
@@ -9,13 +9,15 @@
|
|||||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import {
|
import {
|
||||||
Save, Lock, User, Shield, Plus, Mail, Trash2, Users,
|
Save, Lock, User, Shield, Plus, Mail, Trash2,
|
||||||
Eye, EyeOff, CheckCircle, X,
|
Eye, EyeOff, CheckCircle, X,
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { apiFetch } from '../services/api'
|
import { apiFetch } from '../services/api'
|
||||||
|
import { throwApiProblem } from '../api/contracts'
|
||||||
import OpenClawConfigEditor from '../components/openclaw/OpenClawConfigEditor.vue'
|
import OpenClawConfigEditor from '../components/openclaw/OpenClawConfigEditor.vue'
|
||||||
import OpenClawSetupCenter from '../components/openclaw/OpenClawSetupCenter.vue'
|
import OpenClawSetupCenter from '../components/openclaw/OpenClawSetupCenter.vue'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -119,7 +121,7 @@ interface AdminUser {
|
|||||||
|
|
||||||
const users = ref<AdminUser[]>([])
|
const users = ref<AdminUser[]>([])
|
||||||
const usersLoading = ref(false)
|
const usersLoading = ref(false)
|
||||||
const usersError = ref('')
|
const usersProblem = ref<unknown>(null)
|
||||||
const showCreateUser = ref(false)
|
const showCreateUser = ref(false)
|
||||||
const createEmail = ref('')
|
const createEmail = ref('')
|
||||||
const createPassword = ref('')
|
const createPassword = ref('')
|
||||||
@@ -134,13 +136,13 @@ const canManageUsers = auth.user?.role === 'owner' || auth.user?.role === 'admin
|
|||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
if (!canManageUsers) return
|
if (!canManageUsers) return
|
||||||
usersLoading.value = true
|
usersLoading.value = true
|
||||||
usersError.value = ''
|
usersProblem.value = null
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/v1/admin/users')
|
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()
|
users.value = await res.json()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
usersError.value = e instanceof Error ? e.message : 'Benutzer konnten nicht geladen werden'
|
usersProblem.value = e
|
||||||
} finally {
|
} finally {
|
||||||
usersLoading.value = false
|
usersLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -339,20 +341,33 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<AsyncStatePanel
|
||||||
<div class="pulse-bar"></div>
|
v-else-if="usersProblem && !users.length"
|
||||||
<div class="pulse-bar"></div>
|
state="error"
|
||||||
<div class="pulse-bar"></div>
|
title="Benutzer nicht verfügbar"
|
||||||
</div>
|
:problem="usersProblem"
|
||||||
|
compact
|
||||||
|
@action="loadUsers"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="users.length === 0" class="empty-state">
|
<template v-else-if="users.length">
|
||||||
<Users :size="32" />
|
<AsyncStatePanel
|
||||||
<p>Noch keine Benutzer. Lege den ersten an.</p>
|
v-if="usersProblem"
|
||||||
</div>
|
state="stale"
|
||||||
|
title="Benutzerliste möglicherweise veraltet"
|
||||||
<div v-else class="user-list">
|
: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 v-for="user in users" :key="user.id" class="user-row">
|
||||||
<div class="user-avatar">
|
<div class="user-avatar">
|
||||||
{{ user.displayName.charAt(0).toUpperCase() }}
|
{{ user.displayName.charAt(0).toUpperCase() }}
|
||||||
@@ -371,6 +386,15 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- Create User Modal -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
import { useOpenClawOverviewQuery } from '../api/openclawRuntime'
|
||||||
import { useTaskBoard } from '../api/taskBoard'
|
import { useTaskBoard } from '../api/taskBoard'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
import { useTaskActivity, useTaskChildren } from '../api/tasks'
|
import { useTaskActivity, useTaskChildren } from '../api/tasks'
|
||||||
import { subscribeDomainEventState } from '../services/domainEvents'
|
import { subscribeDomainEventState } from '../services/domainEvents'
|
||||||
import type { SseConnectionState } from '../services/sseHub'
|
import type { SseConnectionState } from '../services/sseHub'
|
||||||
@@ -603,18 +604,32 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="taskBoard.query.isLoading.value" class="board-loading">
|
<AsyncStatePanel
|
||||||
<div class="spinner"></div>
|
v-if="taskBoard.query.isLoading.value && !taskBoard.query.data.value"
|
||||||
<span>Lade Aufgaben…</span>
|
state="loading"
|
||||||
</div>
|
title="Aufgaben werden geladen"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="taskBoard.query.isError.value" class="board-loading" role="alert">
|
<AsyncStatePanel
|
||||||
<AlertTriangle :size="18" />
|
v-else-if="taskBoard.query.isError.value && !taskBoard.query.data.value"
|
||||||
<span>Das Task Board konnte nicht geladen werden.</span>
|
state="error"
|
||||||
<button type="button" class="btn-ghost" @click="taskBoard.query.refetch()">Erneut versuchen</button>
|
title="Task Board konnte nicht geladen werden"
|
||||||
</div>
|
: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
|
<div
|
||||||
class="col"
|
class="col"
|
||||||
:class="{ 'drag-over': dragOverColumn === 'offen' }"
|
:class="{ 'drag-over': dragOverColumn === 'offen' }"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { reconcileTaskBoardCard } from '../api/taskBoard'
|
import { reconcileTaskBoardCard } from '../api/taskBoard'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { reportOperationEnvelope } from '../services/operationResults'
|
import { reportOperationEnvelope } from '../services/operationResults'
|
||||||
|
import AsyncStatePanel from '../components/mission-control/AsyncStatePanel.vue'
|
||||||
import {
|
import {
|
||||||
buildTaskAgentOptions,
|
buildTaskAgentOptions,
|
||||||
taskAgentLabel,
|
taskAgentLabel,
|
||||||
@@ -411,18 +412,20 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
Zurück zum Board
|
Zurück zum Board
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Loading -->
|
<AsyncStatePanel
|
||||||
<div v-if="loading" class="loading-state">
|
v-if="loading && !task"
|
||||||
<div class="spinner"></div>
|
state="loading"
|
||||||
<span>Lade Aufgabe…</span>
|
title="Aufgabe wird geladen"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<!-- Error -->
|
<AsyncStatePanel
|
||||||
<div v-else-if="error" class="error-state">
|
v-else-if="error && !task"
|
||||||
<AlertCircle :size="32" />
|
state="error"
|
||||||
<p>{{ error }}</p>
|
title="Aufgabe konnte nicht geladen werden"
|
||||||
<button type="button" class="btn-primary" @click="loadTask">Erneut versuchen</button>
|
:problem="taskDetail.taskQuery.error.value"
|
||||||
</div>
|
:message="error"
|
||||||
|
@action="loadTask"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Task Detail -->
|
<!-- Task Detail -->
|
||||||
<template v-else-if="task">
|
<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