Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4633e570e8 | |||
| f4bee442db | |||
| 7f1d5b706d | |||
| c3e0e6913b | |||
| b82d88563a | |||
| 7de12c6541 | |||
| b093b0c4b5 | |||
| 2ee1fe973f | |||
| 50d95fa7a9 | |||
| aef76d5f45 | |||
| f564ecfbc7 | |||
| 86ceb2bcce | |||
| 706ff82ccd | |||
| dbda764190 | |||
| 361a64f886 | |||
| a104acf160 | |||
| aaec3eb4ed | |||
| 436ddfee0f | |||
| 38954feb8f | |||
| f30cce4fb3 | |||
| 7216bfdeff | |||
| 16385d10cb | |||
| 250e730f33 | |||
| c9e22195ad | |||
| 8d8f8cc8a8 | |||
| 873c5d586c |
+2
-2
@@ -19,7 +19,7 @@ JWT_AUDIENCE=nexus-web
|
||||
BOOTSTRAP_OWNER_EMAIL=***
|
||||
|
||||
# ── OpenClaw Integration ────────────────────────────────
|
||||
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
# Internal Docker-DNS URL of the OpenClaw gateway
|
||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=***
|
||||
OPENCLAW_GATEWAY_PASSWORD=***
|
||||
|
||||
Executable
+264
@@ -0,0 +1,264 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
|
||||
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
|
||||
ENV_TMPFILE=""
|
||||
BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
|
||||
BOOTSTRAP_OWNER_EMAIL="${BOOTSTRAP_OWNER_EMAIL_DEPLOY:-vmbao62@hotmail.de}"
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
|
||||
shred -u "$ENV_TMPFILE" 2>/dev/null || rm -f "$ENV_TMPFILE"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
require_env() {
|
||||
name="$1"
|
||||
eval "value=\${$name:-}"
|
||||
if [ -z "$value" ]; then
|
||||
echo "Missing required environment variable: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_env ENV_POSTGRES_PASSWORD
|
||||
require_env ENV_JWT_KEY
|
||||
|
||||
secure_tmpfile() {
|
||||
template="$1"
|
||||
dir="$(dirname "$template")"
|
||||
base="$(basename "$template")"
|
||||
mkdir -p "$dir"
|
||||
mktemp "$dir/$base.XXXXXX"
|
||||
}
|
||||
|
||||
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
|
||||
chmod 600 "$ENV_TMPFILE"
|
||||
|
||||
if [ ! -f VERSION ]; then
|
||||
echo "VERSION file not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$(tr -d '[:space:]' < VERSION)"
|
||||
if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Invalid VERSION value: $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GIT_SHA="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||
GIT_REF="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
echo "Deploying Nexus v$VERSION from $GIT_REF"
|
||||
|
||||
umask 077
|
||||
cat > "$ENV_TMPFILE" <<EOF_ENV
|
||||
POSTGRES_DB=nexus
|
||||
POSTGRES_USER=nexus
|
||||
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||
JWT_KEY=${ENV_JWT_KEY}
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
BOOTSTRAP_OWNER_EMAIL=${BOOTSTRAP_OWNER_EMAIL}
|
||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
|
||||
OPENCLAW_GATEWAY_PASSWORD=
|
||||
NEXUS_VERSION=${VERSION}
|
||||
NEXUS_GIT_SHA=${GIT_SHA}
|
||||
EOF_ENV
|
||||
|
||||
echo "Syncing source to deploy path: $DEPLOY_PATH"
|
||||
git archive --format=tar HEAD | docker run --rm -i \
|
||||
-v "$DEPLOY_PATH:/dest" \
|
||||
alpine:3.20 \
|
||||
sh -c '
|
||||
set -eu
|
||||
dest_owner="$(stat -c "%u:%g" /dest)"
|
||||
mkdir -p /src-snapshot
|
||||
tar -xf - -C /src-snapshot
|
||||
|
||||
is_protected_path() {
|
||||
case "$1" in
|
||||
./.git|./.git/*|./.env|./.env.*|./data|./data/*|./logs|./logs/*|./backups|./backups/*|./tmp|./tmp/*|./uploads|./uploads/*|./storage|./storage/*)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cd /dest
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then
|
||||
rm -rf "$path"
|
||||
fi
|
||||
done
|
||||
|
||||
cd /src-snapshot
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then
|
||||
cp -a "$path" /dest/
|
||||
fi
|
||||
done
|
||||
|
||||
chown -R "$dest_owner" /dest
|
||||
'
|
||||
|
||||
# ── Sanitized agents config for Nexus (no secrets) ──
|
||||
echo "Generating sanitized agents config for Nexus"
|
||||
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
|
||||
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
|
||||
if [ -f "$OPENCLAW_CONFIG" ]; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open('$OPENCLAW_CONFIG') as f:
|
||||
data = json.load(f)
|
||||
agents = data.get('agents')
|
||||
if agents is None:
|
||||
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open('$AGENTS_SANITIZED_PATH', 'w') as f:
|
||||
json.dump({'agents': agents}, f, indent=2)
|
||||
"
|
||||
chmod 644 "$AGENTS_SANITIZED_PATH" 2>/dev/null || true
|
||||
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
|
||||
else
|
||||
echo "WARNING: openclaw.json not found at $OPENCLAW_CONFIG — agents-sanitized.json NOT generated" >&2
|
||||
fi
|
||||
|
||||
echo "Building and starting Docker compose stack"
|
||||
docker run --rm \
|
||||
-v "$DEPLOY_PATH:/workspace/nexus" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace/nexus \
|
||||
-i \
|
||||
docker:cli \
|
||||
sh -c 'set -eu
|
||||
umask 077
|
||||
cat > /tmp/nexus-deploy-env
|
||||
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
|
||||
docker compose --env-file /tmp/nexus-deploy-env build
|
||||
|
||||
# ── Postgres: only recreate if image or config changed ──
|
||||
# docker compose up -d (without --force-recreate) is smart enough
|
||||
# to only recreate containers whose config or image has changed.
|
||||
# We DROP --force-recreate so postgres persists across deploys
|
||||
# unless its image tag or compose config actually changed.
|
||||
docker compose --env-file /tmp/nexus-deploy-env up -d --remove-orphans --wait
|
||||
|
||||
docker compose --env-file /tmp/nexus-deploy-env ps
|
||||
' < "$ENV_TMPFILE"
|
||||
|
||||
echo "Verifying image provenance"
|
||||
for container in nexus-api-1 nexus-web-1; do
|
||||
revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$container")"
|
||||
version="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$container")"
|
||||
if [ "$revision" != "$GIT_SHA" ]; then
|
||||
echo "Image revision mismatch for $container: expected $GIT_SHA, got $revision" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$version" != "$VERSION" ]; then
|
||||
echo "Image version mismatch for $container: expected $VERSION, got $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$container provenance verified: v$version $revision"
|
||||
done
|
||||
|
||||
echo "Checking live health"
|
||||
retry=0
|
||||
while [ "$retry" -lt 6 ]; do
|
||||
retry=$((retry + 1))
|
||||
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
|
||||
case "$health_body" in
|
||||
'{"status":"Healthy"'*)
|
||||
echo "Health check passed"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
if [ -n "$health_body" ]; then
|
||||
echo "Health endpoint is reachable but not healthy: $health_body" >&2
|
||||
fi
|
||||
if [ "$retry" -eq 6 ]; then
|
||||
echo "Health check failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep "$retry"
|
||||
done
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
check() {
|
||||
path="$1"
|
||||
expected="$2"
|
||||
label="$3"
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 "$BASE_URL$path")"
|
||||
printf '%-28s HTTP %s\n' "$label" "$code"
|
||||
if [ "$code" = "$expected" ]; then
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_post() {
|
||||
path="$1"
|
||||
expected="$2"
|
||||
label="$3"
|
||||
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -X POST -H 'Content-Type: application/json' --data '{}' "$BASE_URL$path")"
|
||||
printf '%-28s HTTP %s\n' "$label" "$code"
|
||||
if [ "$code" = "$expected" ]; then
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check "/dashboard" "200" "Dashboard"
|
||||
check "/health" "200" "Health"
|
||||
check "/api/v1/operations/snapshot" "401" "Operations auth"
|
||||
check_post "/api/v1/chat" "401" "Chat auth"
|
||||
|
||||
# ── Auth Smoke: SeedAudit owner_created exists in DB ──
|
||||
echo ""
|
||||
echo "Auth Smoke: SeedAudit owner_created"
|
||||
seed_key="$(docker exec nexus-postgres-1 psql -U nexus -d nexus -t -A -c "SELECT key FROM \"SeedAudit\" WHERE key = 'owner_created'" 2>/dev/null || echo "")"
|
||||
seed_key="$(echo "$seed_key" | tr -d '[:space:]')"
|
||||
if [ "$seed_key" = "owner_created" ]; then
|
||||
echo " SeedAudit owner_created: ✅ exists"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " SeedAudit owner_created: ❌ NOT FOUND (DB may not be seeded)" >&2
|
||||
echo " Raw output: '$seed_key'" >&2
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
|
||||
# ── Auth Smoke: Owner login flow returns 401 for unknown password ──
|
||||
# This proves the user exists, auth pipeline is functional, and the DB is reachable.
|
||||
# We POST with a WRONG password intentionally — a 401 means "user found, password wrong",
|
||||
# which is the correct auth flow behavior. A 5xx or connection error means the stack is broken.
|
||||
echo "Auth Smoke: Owner login flow"
|
||||
login_body="$(curl -sS --max-time 10 \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"email\":\"${BOOTSTRAP_OWNER_EMAIL}\",\"password\":\"smoke-test-wrong-password-$(date +%s)\"}" \
|
||||
"$BASE_URL/api/v1/auth/login" 2>/dev/null || echo "CONNECTION_ERROR")"
|
||||
|
||||
if echo "$login_body" | grep -q '"error":"invalid_credentials"'; then
|
||||
echo " Owner login flow: ✅ HTTP 401 with valid JSON (auth pipeline working)"
|
||||
pass=$((pass + 1))
|
||||
else
|
||||
echo " Owner login flow: ❌ unexpected response" >&2
|
||||
echo " Response: $(echo "$login_body" | head -c 200)" >&2
|
||||
fail=$((fail + 1))
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Smoke test failed: $fail failed, $pass passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Nexus v$VERSION deployed and verified"
|
||||
@@ -33,7 +33,7 @@ on:
|
||||
host_backup_path:
|
||||
description: 'Host path for backup (only if keep_on_host is true)'
|
||||
required: false
|
||||
default: '/home/projekte_bao/openclaw/backups'
|
||||
default: '/home/projekte_bao/backups/nexus'
|
||||
type: string
|
||||
|
||||
# Optional: uncomment to enable nightly automatic backups
|
||||
@@ -43,11 +43,11 @@ on:
|
||||
jobs:
|
||||
backup:
|
||||
name: Backup PostgreSQL
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux
|
||||
env:
|
||||
ENV_TMPFILE: /tmp/nexus-backup-env
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||
BACKUP_CONTAINER_NAME: nexus-postgres-1
|
||||
|
||||
steps:
|
||||
|
||||
@@ -8,9 +8,12 @@ concurrency:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches:
|
||||
- main
|
||||
- 'codex/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# ─── Backend ───────────────────────────────────
|
||||
@@ -73,7 +76,7 @@ jobs:
|
||||
security:
|
||||
name: Security Check
|
||||
runs-on: linux
|
||||
if: github.ref == 'refs/heads/main'
|
||||
if: gitea.ref == 'refs/heads/main' || startsWith(gitea.ref, 'refs/heads/codex/')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -97,3 +100,25 @@ jobs:
|
||||
else
|
||||
echo "✅ No obvious secrets found"
|
||||
fi
|
||||
|
||||
deploy:
|
||||
name: Deploy Nexus
|
||||
runs-on: linux
|
||||
needs: [backend, frontend, security]
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
if: |
|
||||
gitea.event_name == 'push' &&
|
||||
gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy after green CI
|
||||
run: sh .gitea/scripts/deploy-nexus.sh
|
||||
|
||||
@@ -1,364 +1,28 @@
|
||||
name: Deploy Nexus v2
|
||||
run-name: 🚀 Deploy v2 by @${{ gitea.actor }}
|
||||
name: Deploy Nexus Manual
|
||||
run-name: Deploy Nexus manually by @${{ gitea.actor }}
|
||||
|
||||
# ───────────────────────────────────────────────────────
|
||||
# Owner: DevOps (Architekt)
|
||||
# CD v3 — 2026-06-13
|
||||
#
|
||||
# Triggers:
|
||||
# 1. AUTOMATIC after successful CI on main (workflow_run)
|
||||
# → Deploys main with the VERSION already present in the repo.
|
||||
# → Commits marked with [skip ci] are filtered at job level
|
||||
# (prevents version-bump loops).
|
||||
# 2. MANUAL via workflow_dispatch with full parameter control.
|
||||
#
|
||||
# Concurrency: one deploy at a time.
|
||||
# Queued deploys wait — no race conditions with parallel builds.
|
||||
#
|
||||
# Version Management:
|
||||
# The VERSION file in the repo root is the single source of truth.
|
||||
# Deploy only reads, validates, and logs the version.
|
||||
# Version changes happen before merge to main, not during deploy.
|
||||
# ───────────────────────────────────────────────────────
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
|
||||
on:
|
||||
# ── Auto-Trigger: after successful CI on main ──
|
||||
workflow_run:
|
||||
workflows: ["CI - Build & Test"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
|
||||
# ── Manual Trigger (full control) ──
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy Nexus
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch') ||
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
|
||||
|
||||
# ── Env for the deploy target path ──
|
||||
runs-on: linux
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||
ENV_TMPFILE: /tmp/nexus-deploy-env
|
||||
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||
# Owner password is not injected at deploy time.
|
||||
# After first seed, the database is the only password source.
|
||||
|
||||
steps:
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 1: Checkout
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Checkout
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 2: Set up Git identity
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config user.email "devops@noveria.net"
|
||||
git config user.name "DevOps"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 3: Resolve deploy version
|
||||
#
|
||||
# Reads VERSION from repo root — the single source of truth.
|
||||
# Validates semver format, logs version + git metadata.
|
||||
# No git mutation: version bumps happen in the Dev workflow.
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Resolve Version
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1. Check VERSION exists
|
||||
if [ ! -f VERSION ]; then
|
||||
echo "❌ VERSION file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Read and validate semver format
|
||||
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "❌ Invalid semver in VERSION: '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Log version, git ref, and describe
|
||||
GIT_REF=$(git rev-parse --short HEAD)
|
||||
GIT_DESCRIBE=$(git describe --always --dirty)
|
||||
|
||||
echo "📦 Deploy version: v${VERSION}"
|
||||
echo "🔖 Git ref: ${GIT_REF}"
|
||||
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
|
||||
|
||||
# 4. Set outputs for downstream steps
|
||||
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
|
||||
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 4: Build .env from secrets (SAFE)
|
||||
#
|
||||
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
||||
# to a file inside the workspace that gets rsync'd to
|
||||
# the host. The temp file is deleted immediately after
|
||||
# compose operations complete.
|
||||
#
|
||||
# Owner password is deliberately omitted so production deploys
|
||||
# cannot overwrite the persisted DB password.
|
||||
# Other secrets (POSTGRES_PASSWORD, JWT_KEY, OPENCLAW_TOKEN)
|
||||
# come from Gitea secrets.
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Prepare .env (secrets → temp file)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
cat > "${ENV_TMPFILE}" <<EOF
|
||||
# Nexus Production Environment — auto-generated by CD pipeline
|
||||
# Managed via Gitea Secrets → do NOT edit manually.
|
||||
# This file lives in /tmp and is removed after deploy completes.
|
||||
POSTGRES_DB=nexus
|
||||
POSTGRES_USER=nexus
|
||||
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||
JWT_KEY=${ENV_JWT_KEY}
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||
OPENCLAW_GATEWAY_PASSWORD=
|
||||
EOF
|
||||
|
||||
chmod 600 "${ENV_TMPFILE}"
|
||||
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 5: Sync code to host (without .env in workspace)
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Sync code to host
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm \
|
||||
-v "${{ gitea.workspace }}:/src:ro" \
|
||||
-v "${DEPLOY_PATH}:/dest" \
|
||||
alpine:latest \
|
||||
sh -c "
|
||||
cd /src && \
|
||||
find . -mindepth 1 -maxdepth 1 \
|
||||
! -name .git \
|
||||
-exec cp -r {} /dest/ \; && \
|
||||
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||
chown -R \"\$DEST_OWNER\" /dest
|
||||
"
|
||||
|
||||
echo "✅ Code synced to ${DEPLOY_PATH}"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 6: Build & Deploy
|
||||
#
|
||||
# The temp .env file is bind-mounted read-only into the
|
||||
# docker:cli container so compose can resolve variables.
|
||||
# It is NEVER written into the workspace directory.
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Build & Deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
BUILD_ARGS=""
|
||||
SERVICE_ARG=""
|
||||
|
||||
# Write the deploy script to a file to avoid nested quoting issues
|
||||
cat > /tmp/nexus-deploy-script.sh << 'DEPLOYSCRIPT'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||
cat > /tmp/nexus-deploy-env
|
||||
|
||||
# ── Graceful shutdown (preserves DB volume integrity) ──
|
||||
docker compose --env-file /tmp/nexus-deploy-env stop postgres 2>/dev/null || true
|
||||
docker compose --env-file /tmp/nexus-deploy-env down --remove-orphans 2>/dev/null || true
|
||||
echo "Postgres volume preserved (nexus-postgres) — no WAL reset"
|
||||
|
||||
BUILD_ARGS="${DEPLOY_BUILD_ARGS:-}"
|
||||
SERVICE="${DEPLOY_SERVICE:-}"
|
||||
|
||||
if [ -n "$SERVICE" ]; then
|
||||
echo "Deploying service: $SERVICE"
|
||||
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS $SERVICE
|
||||
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate $SERVICE
|
||||
else
|
||||
echo 'Deploying all services'
|
||||
docker compose --env-file /tmp/nexus-deploy-env build $BUILD_ARGS
|
||||
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate
|
||||
fi
|
||||
|
||||
echo 'Waiting for services to become healthy (up to 180s)...'
|
||||
for i in $(seq 1 36); do
|
||||
STATUS=$(docker compose --env-file /tmp/nexus-deploy-env ps -a 2>/dev/null | tail -n +2)
|
||||
if echo "$STATUS" | grep -q 'unhealthy'; then
|
||||
echo " [$i/36] Unhealthy containers - failing fast"
|
||||
docker compose --env-file /tmp/nexus-deploy-env ps -a
|
||||
docker compose --env-file /tmp/nexus-deploy-env logs --tail=30
|
||||
exit 1
|
||||
elif echo "$STATUS" | grep -q 'starting'; then
|
||||
echo " [$i/36] Still starting..."
|
||||
sleep 5
|
||||
else
|
||||
echo 'All containers healthy'
|
||||
docker compose --env-file /tmp/nexus-deploy-env ps -a
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
echo 'Timeout waiting for services'
|
||||
docker compose --env-file /tmp/nexus-deploy-env ps -a
|
||||
docker compose --env-file /tmp/nexus-deploy-env logs --tail=20
|
||||
exit 1
|
||||
DEPLOYSCRIPT
|
||||
|
||||
docker run --rm \
|
||||
-e "DEPLOY_BUILD_ARGS=${BUILD_ARGS:-}" \
|
||||
-e "DEPLOY_SERVICE=${SERVICE_ARG:-}" \
|
||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v /tmp/nexus-deploy-script.sh:/deploy.sh:ro \
|
||||
-w /workspace/nexus \
|
||||
-i \
|
||||
docker:cli \
|
||||
sh /deploy.sh < "${ENV_TMPFILE}"
|
||||
|
||||
rm -f /tmp/nexus-deploy-script.sh
|
||||
|
||||
echo "✅ Docker compose up completed"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 7: Clean up temp .env
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Clean up temp .env
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f "${ENV_TMPFILE}" ]; then
|
||||
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
||||
echo "🧹 Temp .env removed"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 8: Health Check (exponential backoff)
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Health Check
|
||||
run: |
|
||||
echo "🏥 Health check..."
|
||||
RETRY=0
|
||||
MAX=6
|
||||
WAIT=1
|
||||
while [ $RETRY -lt $MAX ]; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||
echo ""
|
||||
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||
sleep $WAIT
|
||||
# Fibonacci-ish backoff: 1,2,3,5,8,13
|
||||
NEXT=$((WAIT + RETRY))
|
||||
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
||||
done
|
||||
echo "❌ Health check failed after $MAX attempts"
|
||||
exit 1
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 9: Smoke Test
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Smoke Test
|
||||
run: |
|
||||
echo "🔍 Smoke test..."
|
||||
PASS=0
|
||||
FAIL=0
|
||||
BASE="https://nexus.noveria.net"
|
||||
|
||||
check() {
|
||||
local path="$1" label="$2" expected="${3:-200}"
|
||||
local code
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||
if [ "$code" = "$expected" ]; then
|
||||
echo " ✅"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ (expected $expected)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check "/dashboard" "Dashboard" 200
|
||||
check "/health" "Health API" 200
|
||||
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||
|
||||
echo ""
|
||||
echo "Results: $PASS passed, $FAIL failed"
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo "❌ Smoke test failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 10: Deployment Summary
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: Deployment Summary
|
||||
if: always()
|
||||
run: |
|
||||
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " 📦 Deploy Summary"
|
||||
echo "═══════════════════════════════════════"
|
||||
echo " Version: v${{ steps.version.outputs.version }}"
|
||||
echo " Git ref: main"
|
||||
echo " Service: all"
|
||||
echo " Trigger: ${TRIGGER}"
|
||||
echo " Actor: @${{ gitea.actor }}"
|
||||
echo " Status: ${{ job.status }}"
|
||||
echo "═══════════════════════════════════════"
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# Step 11: Failure → Reviewer Handoff
|
||||
#
|
||||
# On failure: DevOps (Architekt) analyses the log,
|
||||
# notifies Reviewer (Code-Fixer) with the exact error.
|
||||
# This output provides a ready-to-copy message.
|
||||
# ═══════════════════════════════════════════════════
|
||||
- name: 🔴 Failure — Reviewer Handoff
|
||||
if: failure()
|
||||
run: |
|
||||
echo ""
|
||||
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
|
||||
echo "├─────────────────────────────────────────────────────────────┤"
|
||||
echo "│ │"
|
||||
echo "│ Version: v${{ steps.version.outputs.version }}"
|
||||
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||
echo "│ │"
|
||||
echo "│ → DevOps (Architekt) analysiert den Fehler │"
|
||||
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
|
||||
echo "│ → DevOps verifiziert mit neuem Deploy │"
|
||||
echo "│ │"
|
||||
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
|
||||
echo "│ workflow manuell in Gitea Actions. │"
|
||||
echo "│ │"
|
||||
echo "└─────────────────────────────────────────────────────────────┘"
|
||||
- name: Deploy main
|
||||
run: sh .gitea/scripts/deploy-nexus.sh
|
||||
|
||||
@@ -40,9 +40,9 @@ on:
|
||||
jobs:
|
||||
rollback:
|
||||
name: Rollback Nexus
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: linux
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||
DEPLOY_PATH: /home/projekte_bao/nexus
|
||||
ENV_TMPFILE: /tmp/nexus-rollback-env
|
||||
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||
@@ -112,9 +112,11 @@ jobs:
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||
OPENCLAW_GATEWAY_PASSWORD=
|
||||
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
|
||||
NEXUS_GIT_SHA=$(git rev-parse HEAD)
|
||||
EOF
|
||||
|
||||
chmod 600 "${ENV_TMPFILE}"
|
||||
@@ -127,18 +129,38 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm \
|
||||
-v "${{ gitea.workspace }}:/src:ro" \
|
||||
git archive --format=tar HEAD | docker run --rm -i \
|
||||
-v "${DEPLOY_PATH}:/dest" \
|
||||
alpine:latest \
|
||||
sh -c "
|
||||
cd /src && \
|
||||
find . -mindepth 1 -maxdepth 1 \
|
||||
! -name .git \
|
||||
-exec cp -r {} /dest/ \; && \
|
||||
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||
chown -R \"\$DEST_OWNER\" /dest
|
||||
"
|
||||
sh -c '
|
||||
set -eu
|
||||
dest_owner="$(stat -c "%u:%g" /dest)"
|
||||
mkdir -p /src-snapshot
|
||||
tar -xf - -C /src-snapshot
|
||||
|
||||
is_protected_path() {
|
||||
case "$1" in
|
||||
./.git|./.env|./.env.*|./data|./logs|./backups|./tmp|./uploads|./storage)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cd /dest
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then rm -rf "$path"; fi
|
||||
done
|
||||
|
||||
cd /src-snapshot
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then cp -a "$path" /dest/; fi
|
||||
done
|
||||
|
||||
chown -R "$dest_owner" /dest
|
||||
'
|
||||
|
||||
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||
|
||||
@@ -151,16 +173,18 @@ jobs:
|
||||
|
||||
docker run --rm \
|
||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||
-v "/tmp:/tmp-host:ro" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace/nexus \
|
||||
-i \
|
||||
docker:cli \
|
||||
sh -c "
|
||||
set -e
|
||||
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||
"
|
||||
sh -c '
|
||||
set -eu
|
||||
umask 077
|
||||
cat > /tmp/nexus-rollback-env
|
||||
trap '\''rm -f /tmp/nexus-rollback-env'\'' EXIT INT TERM
|
||||
docker compose --env-file /tmp/nexus-rollback-env build --no-cache
|
||||
docker compose --env-file /tmp/nexus-rollback-env up -d --wait --force-recreate
|
||||
' < "${ENV_TMPFILE}"
|
||||
|
||||
echo "✅ Rollback redeploy completed"
|
||||
|
||||
@@ -186,11 +210,14 @@ jobs:
|
||||
WAIT=1
|
||||
while [ $RETRY -lt $MAX ]; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||
echo ""
|
||||
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||
exit 0
|
||||
fi
|
||||
HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
|
||||
case "$HEALTH_BODY" in
|
||||
'{"status":"Healthy"'*)
|
||||
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
|
||||
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||
sleep $WAIT
|
||||
NEXT=$((WAIT + RETRY))
|
||||
@@ -271,7 +298,7 @@ jobs:
|
||||
echo "│ Letzter bekannter funktionierender Stand: │"
|
||||
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
||||
echo "│ → Manuellen Rollback erwägen: │"
|
||||
echo "│ cd /home/projekte_bao/openclaw/data/openclaw/workspace/nexus │"
|
||||
echo "│ cd /home/projekte_bao/nexus │"
|
||||
echo "│ docker compose up -d (vorheriger Stand) │"
|
||||
echo "│ │"
|
||||
echo "└─────────────────────────────────────────────────────────────┘"
|
||||
|
||||
@@ -39,3 +39,6 @@ frontend/.corepack-home/
|
||||
|
||||
# Claude local config (per-developer, not repo-shared)
|
||||
.claude/
|
||||
|
||||
# Sanitized agent config (generated on host, not committed)
|
||||
backend/agents-sanitized.json
|
||||
|
||||
+6
-5
@@ -31,7 +31,7 @@
|
||||
│ 127.0.0.1:18880 │
|
||||
│ │ │
|
||||
│ ┌───────────────────────────┼───────────────────┐ │
|
||||
│ │ Host nginx reverse proxy │ │ │
|
||||
│ │ Traefik v3 reverse proxy │ │ │
|
||||
│ │ nexus.noveria.net :443 ───┘ │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
@@ -135,10 +135,11 @@ docker compose exec web nginx -t
|
||||
ss -tlnp | grep 18880
|
||||
```
|
||||
|
||||
### Host nginx Reverse Proxy
|
||||
### Traefik Reverse Proxy
|
||||
Falls `nexus.noveria.net` nicht erreichbar:
|
||||
- Host nginx Config prüfen: Proxy-Pass auf `http://127.0.0.1:18880`
|
||||
- TLS-Zertifikat gültig?
|
||||
- Traefik-Labels am `web`-Service prüfen (`traefik.http.routers.nexus.*`)
|
||||
- `web` hängt am externen `proxy`-Netzwerk
|
||||
- TLS-Zertifikat/Let's-Encrypt-Resolver in Traefik gültig?
|
||||
|
||||
---
|
||||
|
||||
@@ -151,5 +152,5 @@ Falls `nexus.noveria.net` nicht erreichbar:
|
||||
| backend/Dockerfile | ✅ Multi-Stage .NET 10 |
|
||||
| frontend/Dockerfile | ✅ Multi-Stage Node 24 + nginx |
|
||||
| frontend/nginx.conf | ✅ CSP, Proxy, SPA-Routing |
|
||||
| Host nginx Reverse Proxy | ⚠️ Muss auf Port 18880 zeigen |
|
||||
| Traefik Reverse Proxy | ✅ Per Compose-Labels auf `web:80` |
|
||||
| Docker installiert auf VPS | ⚠️ Vorausgesetzt |
|
||||
|
||||
@@ -7,7 +7,7 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
||||
> Backend-Brücke und Gateway-Integration geprüft. Siehe
|
||||
> [`docs/architecture-board-first-orchestration.md`](docs/architecture-board-first-orchestration.md)
|
||||
|
||||
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
||||
> CI runs automatically on every push. CD runs **inside the green CI run**
|
||||
> on main or can be triggered **manually** (workflow_dispatch). Deploy reads
|
||||
> `VERSION` but does not mutate Git or create tags. Rollback and database backup
|
||||
> are separate manual workflows.
|
||||
@@ -174,6 +174,63 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|
||||
|
||||
## API endpoints
|
||||
|
||||
### MCP Agent Data Plane
|
||||
|
||||
Nexus exposes an MCP endpoint at `/mcp` for agent-facing board operations.
|
||||
It uses the official `ModelContextProtocol.AspNetCore` SDK with stateless
|
||||
streamable HTTP transport. Tools are a thin facade over `ITaskBridgeService`;
|
||||
they must not duplicate board business logic.
|
||||
|
||||
Auth follows the bridge rules: requests provide `X-Agent-Id` and/or
|
||||
`X-Nexus-Api-Key`. Secrets stay in OpenClaw/Gateway config and are never
|
||||
embedded in frontend code.
|
||||
|
||||
Registered tools:
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `nexus_get_board` | Full task board |
|
||||
| `nexus_agent_overview` | Waiting/stale workflow overview |
|
||||
| `nexus_get_task` | Single task |
|
||||
| `nexus_get_children` | Child tasks for a parent |
|
||||
| `nexus_get_activity` | Task activity history |
|
||||
| `nexus_create_task` | Create parent/standalone task |
|
||||
| `nexus_create_child_task` | Create visible delegation child task |
|
||||
| `nexus_update_status` | Update status using the canonical enum only |
|
||||
| `nexus_append_activity` | Append checkpoint/activity |
|
||||
| `nexus_handoff` | Handoff to a known agent |
|
||||
|
||||
The compatible `/api/bridge` HTTP facade remains available for internal
|
||||
diagnostics and transition clients. New agent integrations should use MCP;
|
||||
`/api/dashboard` is UI/admin surface, not an agent contract.
|
||||
|
||||
### Mission Control Gateway Plane
|
||||
|
||||
Nexus keeps the Browser -> Nexus -> OpenClaw boundary: the frontend never talks
|
||||
to OpenClaw directly. Read-only Gateway status is exposed through
|
||||
`GET /api/dashboard/gateway`; it reports reachability, discovered Gateway
|
||||
version and the optional `Integrations:OpenClaw:RequiredVersion` pin. A set pin
|
||||
does not mutate production config, but makes protocol drift visible in the UI.
|
||||
|
||||
Agent activity shown as "Thinking" is redacted before display. Lines containing
|
||||
token, password, bearer, authorization, API key or secret markers are replaced
|
||||
with a redaction marker. Persisted audit-worthy events should be written as
|
||||
short Activity entries, not raw session transcripts.
|
||||
|
||||
Nexus activity updates stream live through the Dashboard SSE channel and are
|
||||
filtered by explicit `agentIds`. Gateway session history is read-only fallback
|
||||
data: it is fetched on demand, redacted before display and not persisted as a
|
||||
long-term raw transcript. Agent "Now" and "Today" summaries are deterministic
|
||||
derivations from redacted Nexus activity plus redacted Gateway history; Nexus
|
||||
does not call an LLM to summarize this feed.
|
||||
|
||||
Config writes and approval actions are owner-only. Config saves validate before
|
||||
replacement, keep a `.bak` when an existing file is replaced, write audit events
|
||||
without file contents or secrets and return structured `validation`, `backup`
|
||||
and `reloadCheck` results. Workspace Markdown hot reload is currently reported
|
||||
truthfully as `not_supported`; JSON validation exists in the save path but JSON
|
||||
files are not exposed unless they are explicitly allowlisted for editing.
|
||||
|
||||
### Backend Bridge (Agent-zu-Backend, NICHT Frontend)
|
||||
|
||||
Der `/api/bridge/` Pfad ist ein strukturierter MCP-artiger Kommando-Adapter für die
|
||||
@@ -250,11 +307,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow:
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/tasks` | List all tasks |
|
||||
| `POST` | `/api/v1/tasks` | Create task |
|
||||
| `GET` | `/api/v1/tasks/pending-approval` | Tasks in progress older than 1 hour |
|
||||
| `GET` | `/api/v1/tasks/pending-approval` | Owner-only pending approvals |
|
||||
| `PATCH` | `/api/v1/tasks/{id}` | Update task (title, priority, projectId) |
|
||||
| `PATCH` | `/api/v1/tasks/{id}/state` | Update task state |
|
||||
| `POST` | `/api/v1/tasks/{id}/approve` | Approve task (in-progress → done) |
|
||||
| `POST` | `/api/v1/tasks/{id}/reject` | Reject task (in-progress → backlog) |
|
||||
| `POST` | `/api/v1/tasks/{id}/approve` | Owner-only approve task (in-progress -> done) |
|
||||
| `POST` | `/api/v1/tasks/{id}/reject` | Owner-only reject task (in-progress -> backlog) |
|
||||
| `DELETE` | `/api/v1/tasks/{id}` | Delete task (only done/backlog states) |
|
||||
|
||||
### Agents
|
||||
@@ -264,10 +321,11 @@ The Task Board now models OpenClaw delegation as a visible parent/child flow:
|
||||
| `GET` | `/api/v1/agents` | List all agents |
|
||||
| `GET` | `/api/v1/agents/{id}` | Agent detail (with sub-agents, identity) |
|
||||
| `GET` | `/api/v1/agents/{id}/activity` | Agent-specific activity (last 50) |
|
||||
| `GET` | `/api/v1/agents/{id}/summary` | Redacted deterministic Now/Today summary |
|
||||
| `POST` | `/api/v1/agents/{id}/command` | Send command to agent |
|
||||
| `GET` | `/api/v1/agents/{id}/config` | List agent config files (IDENTITY.md, SOUL.md, etc.) |
|
||||
| `GET` | `/api/v1/agents/{id}/config/{fileName}` | Read config file content |
|
||||
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Save config file (atomic write) |
|
||||
| `PUT` | `/api/v1/agents/{id}/config/{fileName}` | Owner-only validated config save with backup/audit/reload result |
|
||||
|
||||
### Memory & Docs
|
||||
|
||||
@@ -351,20 +409,21 @@ Every push to `main` triggers `.gitea/workflows/ci.yaml`:
|
||||
|
||||
CI must never break. If it does, Reviewer fixes.
|
||||
|
||||
### CD — Auto + Manual (CD v3)
|
||||
### CD — Auto + Manual (CD v4)
|
||||
|
||||
Deployment can happen automatically or manually:
|
||||
|
||||
#### Auto-Deploy (after successful CI on main)
|
||||
#### Auto-Deploy (after successful CI jobs on main)
|
||||
|
||||
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
|
||||
- Runs as the final `Deploy Nexus` job in `.gitea/workflows/ci.yaml`
|
||||
- Starts only after backend, frontend, and security jobs succeed on `main`
|
||||
- Deploys the current `main` version after CI succeeds.
|
||||
- Skips automatically if the triggering commit contains `[skip ci]`
|
||||
- The deploy workflow reads `VERSION`; it does not mutate Git, bump versions, or create tags
|
||||
- This replaces `workflow_run`, which did not create deploy runs in this Gitea 1.26.3 installation.
|
||||
- The deploy script reads `VERSION`; it does not mutate Git, bump versions, or create tags
|
||||
|
||||
#### Manual Deploy (`workflow_dispatch`)
|
||||
|
||||
1. DevOps triggers `Deploy Nexus v2` in Gitea Actions
|
||||
1. DevOps triggers `Deploy Nexus Manual` in Gitea Actions
|
||||
2. Workflow validates `VERSION`, builds and deploys `main`
|
||||
3. Health check + smoke test verify the deployment
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Nexus.Api.Controllers;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class ChatControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChatController_RequiresAuthorization()
|
||||
{
|
||||
var attribute = typeof(ChatController).GetCustomAttribute<AuthorizeAttribute>();
|
||||
|
||||
Assert.NotNull(attribute);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Regression tests for the SeedAudit-based owner-seed guard in EnsureDatabaseAsync.
|
||||
/// Verifies that once SeedAudit contains "owner_created", subsequent calls to
|
||||
/// EnsureDatabaseAsync (simulating pod restarts) do NOT reset the owner's password hash.
|
||||
/// </summary>
|
||||
public sealed class EnsureDatabaseSeedAuditTests
|
||||
{
|
||||
private const string SeedKey = "owner_created";
|
||||
|
||||
/// <summary>
|
||||
/// Creates an in-memory DbContext pre-seeded with an owner user and a SeedAudit row.
|
||||
/// </summary>
|
||||
private static async Task<(NexusDbContext db, NexusUser owner, string originalHash)> CreateSeededFixtureAsync()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var db = new NexusDbContext(options);
|
||||
|
||||
const string originalPassword = "InitialOwnerPassword123!";
|
||||
var originalHash = PasswordSecurity.Hash(originalPassword);
|
||||
|
||||
var owner = new NexusUser
|
||||
{
|
||||
Email = "owner@nexus.internal",
|
||||
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
|
||||
DisplayName = "Nexus Owner",
|
||||
PasswordHash = originalHash,
|
||||
Role = "owner"
|
||||
};
|
||||
db.Users.Add(owner);
|
||||
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (db, owner, originalHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates the restart guard: if SeedAudit contains owner_created,
|
||||
/// the owner password hash must not be changed to a newly generated hash.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash()
|
||||
{
|
||||
// Arrange — seed the DB with an owner and a SeedAudit row
|
||||
var (db, owner, originalHash) = await CreateSeededFixtureAsync();
|
||||
|
||||
// Sanity check: password hash starts as expected
|
||||
Assert.Equal(originalHash, owner.PasswordHash);
|
||||
Assert.True(PasswordSecurity.Verify("InitialOwnerPassword123!", owner.PasswordHash, out _));
|
||||
|
||||
// Act — simulate a password change by the user
|
||||
const string newPassword = "ChangedOwnerPassword456!";
|
||||
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Detach and re-read to confirm the change persisted
|
||||
db.ChangeTracker.Clear();
|
||||
var afterChange = await db.Users.FirstAsync(u => u.Id == owner.Id);
|
||||
Assert.NotEqual(originalHash, afterChange.PasswordHash);
|
||||
Assert.True(PasswordSecurity.Verify(newPassword, afterChange.PasswordHash, out _));
|
||||
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterChange.PasswordHash, out _));
|
||||
|
||||
// Act — simulate EnsureDatabaseAsync on restart:
|
||||
// It checks SeedAudit first; if owner_created exists, it returns immediately.
|
||||
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||
Assert.True(alreadySeeded, "SeedAudit should contain owner_created after initial seed");
|
||||
|
||||
if (alreadySeeded)
|
||||
{
|
||||
// EnsureDatabaseAsync returns here — owner is NOT touched
|
||||
}
|
||||
|
||||
// Assert — after the "restart", the password hash must still be the changed one
|
||||
db.ChangeTracker.Clear();
|
||||
var afterRestart = await db.Users.FirstAsync(u => u.Id == owner.Id);
|
||||
Assert.Equal(afterChange.PasswordHash, afterRestart.PasswordHash);
|
||||
Assert.NotEqual(originalHash, afterRestart.PasswordHash);
|
||||
Assert.True(PasswordSecurity.Verify(newPassword, afterRestart.PasswordHash, out _),
|
||||
"Changed password must still work after simulated restart");
|
||||
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterRestart.PasswordHash, out _),
|
||||
"Original seed password must NOT work after simulated restart");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulates a full restart: creates a completely new DbContext (simulating a new pod),
|
||||
/// and verifies the SeedAudit guard prevents owner re-seeding.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword()
|
||||
{
|
||||
// Arrange — create and seed the first "instance"
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
Guid ownerId;
|
||||
string changedHash;
|
||||
|
||||
// First "pod run": seed owner + SeedAudit, then change password
|
||||
await using (var db1 = new NexusDbContext(options))
|
||||
{
|
||||
const string initialPassword = "SeedPassword123!";
|
||||
var owner = new NexusUser
|
||||
{
|
||||
Email = "owner@nexus.internal",
|
||||
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
|
||||
DisplayName = "Nexus Owner",
|
||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||
Role = "owner"
|
||||
};
|
||||
db1.Users.Add(owner);
|
||||
db1.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||
await db1.SaveChangesAsync();
|
||||
ownerId = owner.Id;
|
||||
|
||||
// Password change
|
||||
const string newPassword = "NewSecurePassword789!";
|
||||
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
|
||||
await db1.SaveChangesAsync();
|
||||
changedHash = owner.PasswordHash;
|
||||
}
|
||||
|
||||
// Act — second "pod run": new DbContext, simulate EnsureDatabaseAsync
|
||||
await using (var db2 = new NexusDbContext(options))
|
||||
{
|
||||
var alreadySeeded = await db2.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||
Assert.True(alreadySeeded, "SeedAudit must persist across DbContext instances");
|
||||
|
||||
// EnsureDatabaseAsync would return here because alreadySeeded is true
|
||||
// No user creation or password reset happens
|
||||
|
||||
var owner = await db2.Users.FirstAsync(u => u.Id == ownerId);
|
||||
Assert.Equal(changedHash, owner.PasswordHash);
|
||||
Assert.True(PasswordSecurity.Verify("NewSecurePassword789!", owner.PasswordHash, out _),
|
||||
"Changed password must survive a full simulated restart (new DbContext)");
|
||||
Assert.False(PasswordSecurity.Verify("SeedPassword123!", owner.PasswordHash, out _),
|
||||
"Seed password must NOT work after restart");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that if all users are deleted but SeedAudit still has owner_created,
|
||||
/// a restart will NOT re-create the owner (the SeedAudit guard is the single
|
||||
/// source of truth — preventing password drift even if the user table is wiped).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
await using var db = new NexusDbContext(options);
|
||||
|
||||
// SeedAudit exists from a prior run
|
||||
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Users table is empty (simulating a wiped DB or fresh volume with existing SeedAudit)
|
||||
var hasUsers = await db.Users.AnyAsync();
|
||||
Assert.False(hasUsers);
|
||||
|
||||
// Act — simulate restart: SeedAudit check
|
||||
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||
Assert.True(alreadySeeded);
|
||||
|
||||
// EnsureDatabaseAsync returns early because alreadySeeded is true
|
||||
if (alreadySeeded)
|
||||
{
|
||||
// No owner is created
|
||||
}
|
||||
|
||||
// Assert — owner was NOT created (SeedAudit prevents re-seed)
|
||||
hasUsers = await db.Users.AnyAsync();
|
||||
Assert.False(hasUsers,
|
||||
"SeedAudit should prevent owner re-creation even when Users table is empty");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the baseline scenario: without SeedAudit, EnsureDatabaseAsync
|
||||
/// would proceed to seed a new owner (this is the pre-guard behavior,
|
||||
/// documented here for completeness).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner()
|
||||
{
|
||||
// Arrange
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
await using var db = new NexusDbContext(options);
|
||||
|
||||
// No SeedAudit, no users — this is a fresh DB
|
||||
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
|
||||
Assert.False(alreadySeeded);
|
||||
|
||||
var hasUsers = await db.Users.AnyAsync();
|
||||
Assert.False(hasUsers);
|
||||
|
||||
// Act — simulate the seed path (what EnsureDatabaseAsync would do when !alreadySeeded && !hasUsers)
|
||||
if (!alreadySeeded && !hasUsers)
|
||||
{
|
||||
// This is what EnsureDatabaseAsync would do: create owner + seed audit
|
||||
db.Users.Add(new NexusUser
|
||||
{
|
||||
Email = "owner@nexus.internal",
|
||||
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
|
||||
DisplayName = "Nexus Owner",
|
||||
PasswordHash = PasswordSecurity.Hash("GeneratedTempPassword"),
|
||||
Role = "owner"
|
||||
});
|
||||
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Assert — owner now exists
|
||||
hasUsers = await db.Users.AnyAsync();
|
||||
Assert.True(hasUsers);
|
||||
Assert.True(await db.SeedAudits.AnyAsync(s => s.Key == SeedKey));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class MissionControlPhaseTests
|
||||
{
|
||||
[Fact]
|
||||
public void AgentConfigSave_IsBaoOwnerOnly()
|
||||
{
|
||||
var method = typeof(AgentsController).GetMethod(nameof(AgentsController.SaveConfigFile), BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var authorize = method!.GetCustomAttribute<AuthorizeAttribute>();
|
||||
Assert.NotNull(authorize);
|
||||
Assert.Equal("owner", authorize!.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskApprovalEndpoints_AreOwnerOnly()
|
||||
{
|
||||
var pending = typeof(TasksController).GetMethod(nameof(TasksController.GetPendingApproval), BindingFlags.Instance | BindingFlags.Public);
|
||||
var approve = typeof(TasksController).GetMethod(nameof(TasksController.Approve), BindingFlags.Instance | BindingFlags.Public);
|
||||
var reject = typeof(TasksController).GetMethod(nameof(TasksController.Reject), BindingFlags.Instance | BindingFlags.Public);
|
||||
|
||||
Assert.Equal("owner", pending!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
|
||||
Assert.Equal("owner", approve!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
|
||||
Assert.Equal("owner", reject!.GetCustomAttribute<AuthorizeAttribute>()?.Roles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GatewayActivityRedaction_RemovesSensitiveLines()
|
||||
{
|
||||
var text = OpenClawGatewayClient.RedactSensitiveText("""
|
||||
Status: ok
|
||||
Authorization: Bearer abc.def.ghi
|
||||
Next step ready
|
||||
X-Nexus-Api-Key: secret
|
||||
""");
|
||||
|
||||
Assert.Contains("Status: ok", text);
|
||||
Assert.Contains("Next step ready", text);
|
||||
Assert.DoesNotContain("Bearer abc", text);
|
||||
Assert.DoesNotContain("secret", text);
|
||||
Assert.Equal(2, text.Split("[redacted sensitive line]").Length - 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentSummaryBuilder_ProducesStructuredNowAndTodaySummary()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var activity = new[]
|
||||
{
|
||||
new ActivityEvent
|
||||
{
|
||||
Type = "agent_task",
|
||||
Message = "programmer completed repo scan",
|
||||
CreatedAt = now.AddHours(-3)
|
||||
}
|
||||
};
|
||||
|
||||
var gateway = new[]
|
||||
{
|
||||
new AgentActivityEntry("5m ago", "Authorization: Bearer hidden\nWorking on redaction", now.AddMinutes(-5)),
|
||||
new AgentActivityEntry("20m ago", "Checking task mapping", now.AddMinutes(-20))
|
||||
};
|
||||
|
||||
var summary = AgentSummaryBuilder.Build(activity, gateway, now);
|
||||
|
||||
Assert.Equal("gateway-session-history", summary.Now.Source);
|
||||
Assert.Equal(now.AddMinutes(-5), summary.Now.Timestamp);
|
||||
Assert.DoesNotContain("Bearer hidden", summary.Now.Text);
|
||||
Assert.Contains("Working on redaction", summary.Now.Text);
|
||||
Assert.Equal("derived-mixed", summary.Today.Source);
|
||||
Assert.Equal(now.AddMinutes(-5), summary.Today.Timestamp);
|
||||
Assert.Contains("Working on redaction", summary.Today.Text);
|
||||
Assert.Contains("Checking task mapping", summary.Today.Text);
|
||||
Assert.Contains("programmer completed repo scan", summary.Today.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivityRepository_RedactsBeforePersistenceAndPublishesAgentIds()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
await using var db = new NexusDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var liveUpdates = new LiveUpdateService();
|
||||
var subscription = await liveUpdates.SubscribeAsync();
|
||||
var repository = new ActivityRepository(db, liveUpdates);
|
||||
|
||||
await repository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "agent",
|
||||
Message = "Command sent to agent programmer: Authorization: Bearer secret-token"
|
||||
});
|
||||
|
||||
var stored = await repository.GetRecentAsync(1);
|
||||
Assert.Single(stored);
|
||||
Assert.DoesNotContain("secret-token", stored[0].Message);
|
||||
Assert.Contains("programmer", stored[0].Message);
|
||||
Assert.Contains("Authorization: Bearer [redacted]", stored[0].Message);
|
||||
|
||||
var envelope = await subscription.Reader.ReadAsync();
|
||||
Assert.Equal("activity.created", envelope.Type);
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(envelope.Payload);
|
||||
using var doc = JsonDocument.Parse(payloadJson);
|
||||
Assert.Equal("agent", doc.RootElement.GetProperty("Type").GetString());
|
||||
Assert.DoesNotContain("secret-token", doc.RootElement.GetProperty("Message").GetString());
|
||||
var agentIds = doc.RootElement.GetProperty("agentIds").EnumerateArray().Select(x => x.GetString()).ToArray();
|
||||
Assert.Contains("programmer", agentIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivityRepository_GetByAgentAsync_UsesMappedAgentIds()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
await using var db = new NexusDbContext(options);
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
var repository = new ActivityRepository(db, new LiveUpdateService());
|
||||
await repository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "agent",
|
||||
Message = "Command sent to agent programmer: compile module"
|
||||
});
|
||||
await repository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "agent",
|
||||
Message = "Command sent to agent reviewer: inspect module"
|
||||
});
|
||||
|
||||
var programmerEvents = await repository.GetByAgentAsync("programmer", 10);
|
||||
|
||||
Assert.Single(programmerEvents);
|
||||
Assert.True(programmerEvents[0].Message.Contains("programmer", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.False(programmerEvents[0].Message.Contains("reviewer", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayInfo_ReportsVersionDrift()
|
||||
{
|
||||
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"version":"2026.07.08"}""", Encoding.UTF8, "application/json")
|
||||
}, requiredVersion: "2026.07.09");
|
||||
|
||||
var info = await client.GetGatewayInfoAsync();
|
||||
|
||||
Assert.True(info.Reachable);
|
||||
Assert.Equal("2026.07.08", info.Version);
|
||||
Assert.Equal("2026.07.09", info.RequiredVersion);
|
||||
Assert.Equal("drift", info.VersionStatus);
|
||||
Assert.False(info.VersionMatches);
|
||||
Assert.NotNull(info.Warning);
|
||||
Assert.Contains("2026.07.08", info.Warning!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayInfo_ReportsMissingVersionWhenPinned()
|
||||
{
|
||||
var client = CreateClient(_ => new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
|
||||
}, requiredVersion: "2026.07.09");
|
||||
|
||||
var info = await client.GetGatewayInfoAsync();
|
||||
|
||||
Assert.True(info.Reachable);
|
||||
Assert.Null(info.Version);
|
||||
Assert.Equal("missing", info.VersionStatus);
|
||||
Assert.False(info.VersionMatches);
|
||||
Assert.NotNull(info.Warning);
|
||||
Assert.Contains("2026.07.09", info.Warning!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GatewayInfo_ReportsMatchedPinnedVersion()
|
||||
{
|
||||
var client = CreateClient(request =>
|
||||
{
|
||||
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json")
|
||||
};
|
||||
response.Headers.Add("X-OpenClaw-Version", "2026.07.09");
|
||||
return response;
|
||||
}, requiredVersion: "2026.07.09");
|
||||
|
||||
var info = await client.GetGatewayInfoAsync();
|
||||
|
||||
Assert.True(info.Reachable);
|
||||
Assert.Equal("matched", info.VersionStatus);
|
||||
Assert.True(info.VersionMatches);
|
||||
Assert.Null(info.Warning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAgentsAsync_MapsRuntimeStatesFromGatewayStatus()
|
||||
{
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddMinutes(-40).ToString("o");
|
||||
var client = CreateClient(request =>
|
||||
{
|
||||
if (request.RequestUri?.AbsolutePath == "/tools/invoke")
|
||||
{
|
||||
using var doc = JsonDocument.Parse(request.Content!.ReadAsStringAsync().GetAwaiter().GetResult());
|
||||
var agentId = doc.RootElement.GetProperty("args").GetProperty("sessionKey").GetString()!
|
||||
.Split(':', StringSplitOptions.RemoveEmptyEntries)[1];
|
||||
|
||||
object status = agentId switch
|
||||
{
|
||||
"iris" => new { status = "active", isActive = true, currentTask = "Coordinate launch", model = "openai/gpt-5.5" },
|
||||
"programmer" => new { status = "idle", lastActivity = staleTimestamp, model = "openai/gpt-5.4" },
|
||||
"reviewer" => new { status = "failed", error = "gateway timeout", model = "openai/gpt-5.5" },
|
||||
"architekt" => new { status = "unsupported", message = "tool not available", model = "openai/gpt-5.5" },
|
||||
_ => new { status = "ready", model = "openai/gpt-5.5" }
|
||||
};
|
||||
|
||||
return ToolResult(status);
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(System.Net.HttpStatusCode.NotFound);
|
||||
}, agentIds: ["iris", "programmer", "reviewer", "architekt"]);
|
||||
|
||||
var agents = await client.GetAgentsAsync();
|
||||
|
||||
Assert.Collection(agents.OrderBy(a => a.Id),
|
||||
architekt =>
|
||||
{
|
||||
Assert.Equal("architekt", architekt.Id);
|
||||
Assert.Equal("unsupported", architekt.StatusKind);
|
||||
Assert.Equal("Unsupported", architekt.StatusLabel);
|
||||
Assert.Equal("tool not available", architekt.StatusDetail);
|
||||
},
|
||||
iris =>
|
||||
{
|
||||
Assert.Equal("iris", iris.Id);
|
||||
Assert.Equal("connected", iris.StatusKind);
|
||||
Assert.Equal("Arbeitet", iris.StatusLabel);
|
||||
},
|
||||
programmer =>
|
||||
{
|
||||
Assert.Equal("programmer", programmer.Id);
|
||||
Assert.Equal("stale", programmer.StatusKind);
|
||||
Assert.Equal("Stale", programmer.StatusLabel);
|
||||
Assert.NotNull(programmer.StatusDetail);
|
||||
Assert.Contains("40m", programmer.StatusDetail!);
|
||||
},
|
||||
reviewer =>
|
||||
{
|
||||
Assert.Equal("reviewer", reviewer.Id);
|
||||
Assert.Equal("error", reviewer.StatusKind);
|
||||
Assert.Equal("Fehler", reviewer.StatusLabel);
|
||||
Assert.Equal("gateway timeout", reviewer.StatusDetail);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentConfigService_RejectsNullBytesBeforeReplacingFile()
|
||||
{
|
||||
var agentId = $"phase-p4-{Guid.NewGuid():N}";
|
||||
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
|
||||
Directory.CreateDirectory(workspacePath);
|
||||
var configPath = Path.Combine(workspacePath, "TOOLS.md");
|
||||
await File.WriteAllTextAsync(configPath, "original");
|
||||
|
||||
try
|
||||
{
|
||||
var service = new AgentConfigService();
|
||||
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "bad\0content");
|
||||
|
||||
Assert.NotNull(attempt.Failure);
|
||||
Assert.Equal("validation_failed", attempt.Failure!.Code);
|
||||
Assert.Equal("failed", attempt.Failure.Validation.Status);
|
||||
Assert.Contains(attempt.Failure.Validation.Errors, error => error.Contains("null bytes", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal("original", await File.ReadAllTextAsync(configPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(workspacePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentConfigService_ReturnsBackupAndReloadShape_OnSuccessfulSave()
|
||||
{
|
||||
var agentId = $"phase-p4-{Guid.NewGuid():N}";
|
||||
var workspacePath = Path.Combine("/mnt", $"workspace-{agentId}");
|
||||
Directory.CreateDirectory(workspacePath);
|
||||
var configPath = Path.Combine(workspacePath, "TOOLS.md");
|
||||
await File.WriteAllTextAsync(configPath, "before");
|
||||
|
||||
try
|
||||
{
|
||||
var service = new AgentConfigService();
|
||||
var attempt = await service.SaveConfigFileAsync(agentId, "TOOLS.md", "after");
|
||||
|
||||
Assert.NotNull(attempt.SaveResult);
|
||||
var result = attempt.SaveResult!;
|
||||
Assert.Equal("passed", result.Validation.Status);
|
||||
Assert.Equal("markdown", result.Validation.FileKind);
|
||||
Assert.Equal("created", result.Backup.Status);
|
||||
Assert.True(result.Backup.BackupCreated);
|
||||
Assert.Equal("not_supported", result.ReloadCheck.Status);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.ReloadCheck.Message));
|
||||
Assert.Equal("before", await File.ReadAllTextAsync(configPath + ".bak"));
|
||||
Assert.Equal("after", await File.ReadAllTextAsync(configPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(workspacePath, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AgentConfigSave_AuditsFailureWithoutLeakingContent()
|
||||
{
|
||||
var configService = new FakeAgentConfigService(new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"validation_failed",
|
||||
new AgentConfigValidationResult("failed", "markdown", ["Content contains null bytes."]),
|
||||
new AgentConfigBackupResult("not_applicable", false),
|
||||
new AgentConfigReloadCheckResult("not_supported", "No hot reload available."))));
|
||||
var activityRepo = new CapturingActivityRepository();
|
||||
|
||||
var controller = new AgentsController(
|
||||
new FakeAgentService(),
|
||||
new FakeAgentRuntime(),
|
||||
activityRepo,
|
||||
configService,
|
||||
new FakeDashboardService(),
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<AgentsController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, "bao"),
|
||||
new Claim(ClaimTypes.Role, "owner")
|
||||
], "TestAuth"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var result = await controller.SaveConfigFile("programmer", "TOOLS.md", new SaveConfigRequest("secret\0payload"), CancellationToken.None);
|
||||
|
||||
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, statusResult.StatusCode);
|
||||
var audit = Assert.Single(activityRepo.Added);
|
||||
Assert.Equal("config_audit", audit.Type);
|
||||
Assert.Contains("validation=failed", audit.Message);
|
||||
Assert.DoesNotContain("secret", audit.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("payload", audit.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static OpenClawGatewayClient CreateClient(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> responder,
|
||||
string? requiredVersion = null,
|
||||
string[]? agentIds = null)
|
||||
{
|
||||
var configValues = new Dictionary<string, string?>
|
||||
{
|
||||
["Integrations:OpenClaw:RequiredVersion"] = requiredVersion
|
||||
};
|
||||
|
||||
if (agentIds is not null)
|
||||
{
|
||||
var configPath = Path.GetTempFileName();
|
||||
File.WriteAllText(configPath, JsonSerializer.Serialize(new
|
||||
{
|
||||
agents = new
|
||||
{
|
||||
list = agentIds.Select(id => new { id }).ToArray()
|
||||
}
|
||||
}));
|
||||
configValues["AgentConfigPath"] = configPath;
|
||||
}
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(configValues)
|
||||
.Build();
|
||||
|
||||
var httpClient = new HttpClient(new StubHttpMessageHandler(responder))
|
||||
{
|
||||
BaseAddress = new Uri("http://gateway.local")
|
||||
};
|
||||
|
||||
return new OpenClawGatewayClient(httpClient, configuration);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage ToolResult(object payload)
|
||||
=> new(System.Net.HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
JsonSerializer.Serialize(new { ok = true, result = payload }),
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
file sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(responder(request));
|
||||
}
|
||||
|
||||
file sealed class FakeAgentConfigService(AgentConfigSaveAttempt attempt) : IAgentConfigService
|
||||
{
|
||||
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId) => [];
|
||||
|
||||
public Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
|
||||
=> Task.FromResult<AgentConfigFileContent?>(null);
|
||||
|
||||
public Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
=> Task.FromResult(attempt);
|
||||
}
|
||||
|
||||
file sealed class CapturingActivityRepository : IActivityRepository
|
||||
{
|
||||
public List<ActivityEvent> Added { get; } = [];
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
|
||||
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
|
||||
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||
=> Task.FromResult((new List<ActivityEvent>(), 0));
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default) => Task.FromResult(new List<ActivityEvent>());
|
||||
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
Added.Add(activity);
|
||||
return Task.FromResult(activity);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeAgentService : IAgentService
|
||||
{
|
||||
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>([]);
|
||||
|
||||
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<AgentDetail?>(null);
|
||||
|
||||
public Task<IReadOnlySet<string>> GetAllowedAgentIdsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlySet<string>>(new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "iris", "bao", "programmer" });
|
||||
}
|
||||
|
||||
file sealed class FakeAgentRuntime : Nexus.Api.Integrations.IAgentRuntime
|
||||
{
|
||||
public string Name => "fake";
|
||||
|
||||
public Task<Nexus.Api.Integrations.AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Nexus.Api.Integrations.AgentRuntimeStatus("fake", OperationalStatus.Online, TimeSpan.Zero, null));
|
||||
|
||||
public Task<Nexus.Api.Integrations.AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(new Nexus.Api.Integrations.AgentChatResult("fake", agentId, conversationId, "ok"));
|
||||
}
|
||||
|
||||
file sealed class FakeDashboardService : IDashboardService
|
||||
{
|
||||
public Task<DashboardStatus> GetStatusAsync() => Task.FromResult(new DashboardStatus(true, "online", 1, 0));
|
||||
public Task<List<DashboardAgentInfo>> GetAgentsAsync() => Task.FromResult(new List<DashboardAgentInfo>());
|
||||
public Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter) => Task.FromResult(new List<FeedEntry>());
|
||||
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
|
||||
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
|
||||
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
|
||||
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
|
||||
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
|
||||
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
|
||||
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
|
||||
public Task<bool> SetAgentModelAsync(string agentId, string model) => Task.FromResult(false);
|
||||
public Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit) => Task.FromResult(new List<AgentActivityEntry>());
|
||||
public List<ModelOption> GetAvailableModels() => [];
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using ModelContextProtocol.Server;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class NexusMcpToolsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NexusMcpTools_RegistersExpectedToolNames()
|
||||
{
|
||||
var toolNames = typeof(NexusMcpTools)
|
||||
.GetMethods()
|
||||
.Select(method => method.GetCustomAttributes(typeof(McpServerToolAttribute), inherit: false)
|
||||
.OfType<McpServerToolAttribute>()
|
||||
.FirstOrDefault())
|
||||
.Where(attribute => attribute is not null)
|
||||
.Select(attribute => attribute!.Name ?? string.Empty)
|
||||
.Order()
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"nexus_agent_overview",
|
||||
"nexus_append_activity",
|
||||
"nexus_create_child_task",
|
||||
"nexus_create_task",
|
||||
"nexus_get_activity",
|
||||
"nexus_get_board",
|
||||
"nexus_get_children",
|
||||
"nexus_get_task",
|
||||
"nexus_handoff",
|
||||
"nexus_update_status"
|
||||
], toolNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NexusMcpTaskState_OnlyContainsCanonicalStates()
|
||||
{
|
||||
Assert.Equal(
|
||||
[
|
||||
nameof(NexusMcpTaskState.Backlog),
|
||||
nameof(NexusMcpTaskState.InProgress),
|
||||
nameof(NexusMcpTaskState.Blocked),
|
||||
nameof(NexusMcpTaskState.Done),
|
||||
nameof(NexusMcpTaskState.Review)
|
||||
], Enum.GetNames<NexusMcpTaskState>());
|
||||
|
||||
Assert.DoesNotContain("Delegated", Enum.GetNames<NexusMcpTaskState>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpTools_ReadAndWrite_UseBridgeService()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
fixture.SetCallerAgent("iris");
|
||||
var tools = CreateTools(fixture);
|
||||
|
||||
var createResult = await tools.CreateTask(
|
||||
title: "MCP parent",
|
||||
detail: "Created through MCP tool facade",
|
||||
priority: "High",
|
||||
assignedTo: "iris",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.True(createResult.Ok);
|
||||
Assert.NotNull(createResult.Data);
|
||||
Assert.Equal("MCP parent", createResult.Data!.Title);
|
||||
|
||||
var activityResult = await tools.AppendActivity(
|
||||
createResult.Data.Id,
|
||||
"MCP checkpoint",
|
||||
"comment",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(activityResult.Ok);
|
||||
Assert.Equal("MCP checkpoint", activityResult.Data!.Message);
|
||||
|
||||
var statusResult = await tools.UpdateStatus(
|
||||
createResult.Data.Id,
|
||||
NexusMcpTaskState.InProgress,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(statusResult.Ok);
|
||||
Assert.Equal(TaskStateHelper.ToStateString(TaskState.InProgress), statusResult.Data!.State);
|
||||
|
||||
var board = await tools.GetBoard(CancellationToken.None);
|
||||
Assert.Contains(board.InProgress, task => task.Id == createResult.Data.Id);
|
||||
|
||||
var taskResult = await tools.GetTask(createResult.Data.Id, CancellationToken.None);
|
||||
Assert.True(taskResult.Ok);
|
||||
Assert.Equal("MCP parent", taskResult.Data!.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpTools_UpdateStatus_RejectsUnauthorizedAgent()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var task = await fixture.TaskService.CreateDashboardTaskAsync(
|
||||
"Programmer cannot move",
|
||||
"State changes stay with Iris/Bao.",
|
||||
"iris",
|
||||
"Normal",
|
||||
"programmer",
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
fixture.SetCallerAgent("programmer");
|
||||
var tools = CreateTools(fixture);
|
||||
|
||||
var result = await tools.UpdateStatus(task.Id, NexusMcpTaskState.Done, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Equal("nexus_update_status", result.Command);
|
||||
Assert.Contains("not authorized", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpTools_ServiceKey_ResolvesAsNexusSystem()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
fixture.HttpContextAccessor.HttpContext = TaskWorkflowFixture.CreateHttpContext(
|
||||
headers: new Dictionary<string, string>
|
||||
{
|
||||
["X-Nexus-Api-Key"] = "test-service-key"
|
||||
});
|
||||
|
||||
var tools = CreateTools(fixture);
|
||||
var result = await tools.CreateTask(
|
||||
title: "System MCP task",
|
||||
assignedTo: "iris",
|
||||
ct: CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("bao", result.Data!.Source);
|
||||
}
|
||||
|
||||
private static NexusMcpTools CreateTools(TaskWorkflowFixture fixture)
|
||||
=> new(
|
||||
fixture.TaskBridgeService,
|
||||
fixture.AgentService,
|
||||
fixture.HttpContextAccessor,
|
||||
fixture.Configuration,
|
||||
NullLogger<NexusMcpTools>.Instance);
|
||||
}
|
||||
@@ -94,6 +94,8 @@ internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) :
|
||||
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
public sealed class StaleTaskRecoveryTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ResetStaleInProgressTasksAsync_OnlyResetsStaleInProgressTasks_AndWritesActivity()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
|
||||
var staleInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Stale in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
await fixture.ActivityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "comment",
|
||||
Message = "Previous agent note",
|
||||
TaskId = staleInProgress.Id,
|
||||
CreatedAt = staleTimestamp.AddMinutes(15)
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleBlocked = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Blocked task",
|
||||
State = "Blocked",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleReview = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Review task",
|
||||
State = "Review",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleDone = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Done task",
|
||||
State = "Done",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var staleBacklog = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Backlog task",
|
||||
State = "Backlog",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var freshInProgress = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Fresh in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-30),
|
||||
CreatedAt = staleTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var resetCount = await fixture.StaleTaskRecoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, resetCount);
|
||||
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleInProgress.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Blocked", (await fixture.TaskService.GetByIdAsync(staleBlocked.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Review", (await fixture.TaskService.GetByIdAsync(staleReview.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Done", (await fixture.TaskService.GetByIdAsync(staleDone.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("Backlog", (await fixture.TaskService.GetByIdAsync(staleBacklog.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(freshInProgress.Id, CancellationToken.None))!.State);
|
||||
|
||||
var activity = await fixture.TaskService.GetTaskActivityAsync(staleInProgress.Id, CancellationToken.None);
|
||||
var resetActivity = activity.FirstOrDefault(entry => entry.Message.Contains("stale recovery", StringComparison.Ordinal));
|
||||
|
||||
Assert.NotNull(resetActivity);
|
||||
Assert.Contains("reason=stale-recovery", resetActivity!.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("previous status In progress", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("stale reference", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("last activity", resetActivity.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("new status Backlog", resetActivity.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResetStaleInProgressTasksAsync_RevalidatesCurrentTaskBeforeReset()
|
||||
{
|
||||
var staleTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
var taskId = Guid.NewGuid();
|
||||
var staleCandidate = new WorkTask
|
||||
{
|
||||
Id = taskId,
|
||||
Title = "Changed during recovery scan",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
};
|
||||
var currentTask = new WorkTask
|
||||
{
|
||||
Id = taskId,
|
||||
Title = "Changed during recovery scan",
|
||||
State = "Review",
|
||||
Source = "iris",
|
||||
UpdatedAt = staleTimestamp,
|
||||
CreatedAt = staleTimestamp
|
||||
};
|
||||
var taskRepository = new FakeTaskRepository(staleCandidate, currentTask);
|
||||
var activityRepository = new FakeActivityRepository();
|
||||
var liveUpdateService = new FakeLiveUpdateService();
|
||||
var recoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService,
|
||||
new FakeNotificationService());
|
||||
|
||||
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, resetCount);
|
||||
Assert.Equal("Review", currentTask.State);
|
||||
Assert.Equal(0, taskRepository.ResetCount);
|
||||
Assert.Empty(activityRepository.Added);
|
||||
Assert.Equal(0, liveUpdateService.PublishCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
|
||||
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Stalled agent task",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = stalledTimestamp,
|
||||
CreatedAt = stalledTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Fresh in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CreatedAt = stalledTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, flagged);
|
||||
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
|
||||
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
|
||||
|
||||
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
|
||||
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
|
||||
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
|
||||
|
||||
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
|
||||
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||
Assert.Equal(0, flaggedAgain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var backgroundService = new StaleTaskRecoveryBackgroundService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StalledMinutes = 45,
|
||||
IntervalMinutes = 10
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
|
||||
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
|
||||
Assert.Equal(7, flaggedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
fakeRecoveryService.OnCall = () => firstCall.TrySetResult(true);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IStaleTaskRecoveryService>(_ => fakeRecoveryService);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var backgroundService = new StaleTaskRecoveryBackgroundService(
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StalledMinutes = 40,
|
||||
IntervalMinutes = 10
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await backgroundService.StartAsync(cts.Token);
|
||||
await firstCall.Task.WaitAsync(cts.Token);
|
||||
await backgroundService.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(fakeRecoveryService.FlagCallCount >= 1);
|
||||
Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskRecoveryOptions_BindsStaleHoursFromEnvironmentOverride()
|
||||
{
|
||||
const string key = "TaskRecovery__StaleHours";
|
||||
var originalValue = Environment.GetEnvironmentVariable(key);
|
||||
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, "5");
|
||||
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
[$"{StaleTaskRecoveryOptions.SectionName}:StaleHours"] = "2",
|
||||
[$"{StaleTaskRecoveryOptions.SectionName}:IntervalMinutes"] = "30"
|
||||
})
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var options = configuration.GetSection(StaleTaskRecoveryOptions.SectionName).Get<StaleTaskRecoveryOptions>();
|
||||
|
||||
Assert.NotNull(options);
|
||||
Assert.Equal(5, options!.StaleHours);
|
||||
Assert.Equal(30, options.IntervalMinutes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(key, originalValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeTaskRepository(WorkTask staleCandidate, WorkTask currentTask) : ITaskRepository
|
||||
{
|
||||
public int ResetCount { get; private set; }
|
||||
|
||||
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<WorkTask> { staleCandidate });
|
||||
|
||||
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||
=> ValueTask.FromResult<WorkTask?>(id == currentTask.Id ? currentTask : null);
|
||||
|
||||
public Task<bool> TryResetStaleInProgressToBacklogAsync(
|
||||
Guid id,
|
||||
DateTimeOffset staleBefore,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (id != currentTask.Id
|
||||
|| !string.Equals(currentTask.State, "In progress", StringComparison.OrdinalIgnoreCase)
|
||||
|| currentTask.UpdatedAt >= staleBefore)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
ResetCount++;
|
||||
currentTask.State = "Backlog";
|
||||
currentTask.UpdatedAt = updatedAt;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<WorkTask>());
|
||||
|
||||
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default)
|
||||
=> Task.FromResult(task);
|
||||
|
||||
public Task DeleteAsync(WorkTask task, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task<int> CountAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<int> CountByStateAsync(string state, CancellationToken ct = default)
|
||||
=> Task.FromResult(0);
|
||||
|
||||
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<WorkTask?>(null);
|
||||
}
|
||||
|
||||
file sealed class FakeActivityRepository : IActivityRepository
|
||||
{
|
||||
public List<ActivityEvent> Added { get; } = [];
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<List<ActivityEvent>> GetRecentForTasksAsync(IEnumerable<Guid> taskIds, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(
|
||||
string? type,
|
||||
string? sort,
|
||||
int page,
|
||||
int pageSize,
|
||||
CancellationToken ct = default)
|
||||
=> Task.FromResult((new List<ActivityEvent>(), 0));
|
||||
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
=> Task.FromResult(new List<ActivityEvent>());
|
||||
|
||||
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
Added.Add(activity);
|
||||
return Task.FromResult(activity);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeLiveUpdateService : ILiveUpdateService
|
||||
{
|
||||
public int PublishCount { get; private set; }
|
||||
public long CurrentSequence => PublishCount;
|
||||
|
||||
public Task<LiveUpdateSubscription> SubscribeAsync(long? afterSequence = null, CancellationToken ct = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public LiveUpdateEnvelope Publish(string type, object payload, string channel = "dashboard")
|
||||
{
|
||||
PublishCount++;
|
||||
return new LiveUpdateEnvelope(type, DateTimeOffset.UtcNow, payload, PublishCount, channel);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
||||
{
|
||||
public int FlagCallCount { get; private set; }
|
||||
public int ResetCallCount { get; private set; }
|
||||
public TimeSpan LastThreshold { get; private set; }
|
||||
public Action? OnCall { get; set; }
|
||||
|
||||
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||
{
|
||||
FlagCallCount++;
|
||||
LastThreshold = stalledThreshold;
|
||||
OnCall?.Invoke();
|
||||
return Task.FromResult(7);
|
||||
}
|
||||
|
||||
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
ResetCallCount++;
|
||||
LastThreshold = staleThreshold;
|
||||
OnCall?.Invoke();
|
||||
return Task.FromResult(7);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeNotificationService : INotificationService
|
||||
{
|
||||
public List<Notification> Created { get; } = [];
|
||||
|
||||
public Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
|
||||
{
|
||||
var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId };
|
||||
Created.Add(notification);
|
||||
return Task.FromResult(notification);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
|
||||
|
||||
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||
|
||||
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||
|
||||
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
|
||||
}
|
||||
|
||||
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
|
||||
{
|
||||
public T CurrentValue { get; private set; } = currentValue;
|
||||
|
||||
public T Get(string? name) => CurrentValue;
|
||||
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
@@ -178,7 +178,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -190,10 +190,8 @@ public sealed class TaskWorkflowTests
|
||||
};
|
||||
|
||||
var result = await controller.GetBoard(CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -201,7 +199,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -210,10 +208,8 @@ public sealed class TaskWorkflowTests
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status401Unauthorized, httpContext.Response.StatusCode);
|
||||
AssertStatusCode(result, StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -221,7 +217,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -233,10 +229,8 @@ public sealed class TaskWorkflowTests
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
||||
AssertStatusCode(result, StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -244,7 +238,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -253,10 +247,8 @@ public sealed class TaskWorkflowTests
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, httpContext.Response.StatusCode);
|
||||
AssertStatusCode(result, StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -264,7 +256,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -276,10 +268,8 @@ public sealed class TaskWorkflowTests
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -287,7 +277,7 @@ public sealed class TaskWorkflowTests
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration)
|
||||
var controller = new TasksController(fixture.TaskService, fixture.AgentService, fixture.Configuration, fixture.ActivityRepository)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
@@ -296,10 +286,8 @@ public sealed class TaskWorkflowTests
|
||||
};
|
||||
|
||||
var result = await controller.ResetStale(new ResetStaleRequest(2), CancellationToken.None);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
await result.ExecuteAsync(httpContext);
|
||||
|
||||
Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
|
||||
AssertStatusCode(result, StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -370,9 +358,21 @@ public sealed class TaskWorkflowTests
|
||||
Assert.NotNull(updatedParent);
|
||||
Assert.Equal("In progress", updatedParent!.State);
|
||||
}
|
||||
|
||||
private static void AssertStatusCode(IResult result, int expectedStatusCode)
|
||||
{
|
||||
if (expectedStatusCode == StatusCodes.Status403Forbidden)
|
||||
{
|
||||
Assert.Equal("Microsoft.AspNetCore.Http.HttpResults.ForbidHttpResult", result.GetType().FullName);
|
||||
return;
|
||||
}
|
||||
|
||||
var statusResult = Assert.IsAssignableFrom<IStatusCodeHttpResult>(result);
|
||||
Assert.Equal(expectedStatusCode, statusResult.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
{
|
||||
private readonly NexusDbContext _db;
|
||||
|
||||
@@ -383,6 +383,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
IActivityRepository activityRepository,
|
||||
INotificationService notificationService,
|
||||
ILiveUpdateService liveUpdateService,
|
||||
IStaleTaskRecoveryService staleTaskRecoveryService,
|
||||
ITaskService taskService,
|
||||
ITaskBridgeService taskBridgeService,
|
||||
IAgentService agentService,
|
||||
@@ -394,6 +395,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
ActivityRepository = activityRepository;
|
||||
NotificationService = notificationService;
|
||||
LiveUpdateService = liveUpdateService;
|
||||
StaleTaskRecoveryService = staleTaskRecoveryService;
|
||||
TaskService = taskService;
|
||||
TaskBridgeService = taskBridgeService;
|
||||
AgentService = agentService;
|
||||
@@ -405,6 +407,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
public IActivityRepository ActivityRepository { get; }
|
||||
public INotificationService NotificationService { get; }
|
||||
public ILiveUpdateService LiveUpdateService { get; }
|
||||
public IStaleTaskRecoveryService StaleTaskRecoveryService { get; }
|
||||
public ITaskService TaskService { get; }
|
||||
public ITaskBridgeService TaskBridgeService { get; }
|
||||
public IAgentService AgentService { get; }
|
||||
@@ -430,10 +433,15 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
|
||||
var agentService = new AgentService(configuration, new FakeRuntime());
|
||||
var liveUpdateService = new LiveUpdateService();
|
||||
var activityRepository = new ActivityRepository(db);
|
||||
var activityRepository = new ActivityRepository(db, liveUpdateService);
|
||||
var taskRepository = new TaskRepository(db);
|
||||
var notificationService = new NotificationService(db, liveUpdateService);
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = CreateHttpContext(agentId: "iris") };
|
||||
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService,
|
||||
notificationService);
|
||||
|
||||
var taskService = new TaskService(
|
||||
taskRepository,
|
||||
@@ -441,7 +449,8 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
notificationService,
|
||||
agentService,
|
||||
httpContextAccessor,
|
||||
liveUpdateService);
|
||||
liveUpdateService,
|
||||
staleTaskRecoveryService);
|
||||
|
||||
var taskBridgeService = new TaskBridgeService(
|
||||
taskService,
|
||||
@@ -457,6 +466,7 @@ file sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
activityRepository,
|
||||
notificationService,
|
||||
liveUpdateService,
|
||||
staleTaskRecoveryService,
|
||||
taskService,
|
||||
taskBridgeService,
|
||||
agentService,
|
||||
@@ -539,6 +549,7 @@ file sealed class FakeDashboardService : IDashboardService
|
||||
public Task<ChatResponse> SendChatAsync(string agentId, string message) => Task.FromResult(new ChatResponse(true, "", null));
|
||||
public Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset) => Task.FromResult(new List<MessageEntry>());
|
||||
public Task<List<QueueItem>> GetQueueAsync(CancellationToken ct) => Task.FromResult(new List<QueueItem>());
|
||||
public Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct) => Task.FromResult(new GatewayRuntimeInfo(true, "http://gateway", "test", "test", true, true, "matched", DateTimeOffset.UtcNow, "ok"));
|
||||
public Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct) => Task.FromResult(new QueueDeleteResult(QueueDeleteOutcome.Ignored));
|
||||
public Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct) => Task.FromResult(new QueuePriorityResult(QueuePriorityOutcome.Ignored));
|
||||
public Task<AgentModelInfo?> GetAgentModelAsync(string agentId) => Task.FromResult<AgentModelInfo?>(null);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Security.Claims;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
@@ -14,6 +16,7 @@ public class AgentsController(
|
||||
IAgentRuntime runtime,
|
||||
IActivityRepository activityRepo,
|
||||
IAgentConfigService agentConfigService,
|
||||
IDashboardService dashboardService,
|
||||
ILogger<AgentsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
@@ -39,7 +42,25 @@ public class AgentsController(
|
||||
public async Task<IResult> GetAgentActivity(string id, CancellationToken ct)
|
||||
{
|
||||
var items = await activityRepo.GetByAgentAsync(id, 50, ct);
|
||||
return Results.Ok(items.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt }));
|
||||
var activity = items
|
||||
.Select(x => new AgentActivityResponse(x.Id, x.Type, x.Message, x.CreatedAt, "activity"))
|
||||
.ToList();
|
||||
|
||||
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 10);
|
||||
foreach (var entry in gatewayEntries)
|
||||
activity.Add(new AgentActivityResponse(null, "thinking", entry.Text, entry.Timestamp, entry.Source, entry.Time));
|
||||
|
||||
return Results.Ok(activity
|
||||
.OrderByDescending(x => x.At)
|
||||
.Take(50));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/summary")]
|
||||
public async Task<IResult> GetAgentSummary(string id, CancellationToken ct)
|
||||
{
|
||||
var recent = await activityRepo.GetByAgentAsync(id, 25, ct);
|
||||
var gatewayEntries = await dashboardService.GetAgentActivityAsync(id, 8);
|
||||
return Results.Ok(AgentSummaryBuilder.Build(recent, gatewayEntries, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/command")]
|
||||
@@ -84,20 +105,48 @@ public class AgentsController(
|
||||
}
|
||||
|
||||
[HttpPut("{id}/config/{fileName}")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
if (request.Content is null)
|
||||
return Results.BadRequest(new { error = "Content is required." });
|
||||
|
||||
if (request.Content.Length > 500 * 1024)
|
||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||
|
||||
try
|
||||
{
|
||||
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||
return result is null
|
||||
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||
var attempt = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||
var caller = DescribeCaller(HttpContext.User);
|
||||
|
||||
if (attempt.Failure is not null)
|
||||
{
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent
|
||||
{
|
||||
Type = "config_audit",
|
||||
Message = $"Config save rejected agent={id} file={fileName} caller={caller} validation={attempt.Failure.Validation.Status} backup={attempt.Failure.Backup.Status} reload={attempt.Failure.ReloadCheck.Status} code={attempt.Failure.Code}",
|
||||
}, ct);
|
||||
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["content"] = attempt.Failure.Validation.Errors.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
var result = attempt.SaveResult!;
|
||||
|
||||
await activityRepo.AddAsync(new Data.ActivityEvent
|
||||
{
|
||||
Type = "config_audit",
|
||||
Message = $"Config save agent={id} file={fileName} caller={caller} validation={result.Validation.Status} backup={result.Backup.Status} reload={result.ReloadCheck.Status}",
|
||||
}, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.FileName,
|
||||
result.Size,
|
||||
result.ModifiedAt,
|
||||
result.Validation,
|
||||
result.Backup,
|
||||
ReloadCheck = result.ReloadCheck
|
||||
});
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
@@ -116,4 +165,92 @@ public class AgentsController(
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
}
|
||||
|
||||
private static string DescribeCaller(ClaimsPrincipal user)
|
||||
{
|
||||
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.FindFirst(ClaimTypes.Email)?.Value
|
||||
?? user.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
|
||||
return $"{role}:{subject}".ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AgentActivityResponse(
|
||||
long? Id,
|
||||
string Type,
|
||||
string Message,
|
||||
DateTimeOffset At,
|
||||
string Source,
|
||||
string? RelativeTime = null
|
||||
);
|
||||
|
||||
public sealed record AgentSummaryResponse(
|
||||
AgentSummaryItemResponse Now,
|
||||
AgentSummaryItemResponse Today,
|
||||
DateTimeOffset GeneratedAt
|
||||
);
|
||||
|
||||
public sealed record AgentSummaryItemResponse(
|
||||
string Text,
|
||||
string Source,
|
||||
DateTimeOffset? Timestamp
|
||||
);
|
||||
|
||||
public static class AgentSummaryBuilder
|
||||
{
|
||||
public static AgentSummaryResponse Build(
|
||||
IReadOnlyList<Nexus.Api.Data.ActivityEvent> activity,
|
||||
IReadOnlyList<Nexus.Api.Models.AgentActivityEntry> gatewayEntries,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
var points = activity
|
||||
.Select(entry => new SummaryPoint(entry.Message, entry.CreatedAt, "nexus-activity"))
|
||||
.Concat(gatewayEntries.Select(entry => new SummaryPoint(entry.Text, entry.Timestamp, entry.Source)))
|
||||
.Select(point => point with { Text = AgentActivityText.RedactForDisplay(point.Text) })
|
||||
.Where(point => !string.IsNullOrWhiteSpace(point.Text))
|
||||
.OrderByDescending(point => point.Timestamp)
|
||||
.ToList();
|
||||
|
||||
var current = points.FirstOrDefault();
|
||||
var now = current is null
|
||||
? new AgentSummaryItemResponse("Keine aktuelle Aktivitaet.", "none", null)
|
||||
: new AgentSummaryItemResponse(current.Text, current.Source, current.Timestamp);
|
||||
|
||||
var windowStart = nowUtc.AddHours(-24);
|
||||
var todayPoints = points
|
||||
.Where(point => point.Timestamp >= windowStart)
|
||||
.ToList();
|
||||
|
||||
AgentSummaryItemResponse today;
|
||||
if (todayPoints.Count == 0)
|
||||
{
|
||||
today = new AgentSummaryItemResponse("Heute keine verwertbaren Checkpoints.", "none", null);
|
||||
}
|
||||
else
|
||||
{
|
||||
var snippets = todayPoints
|
||||
.Select(point => point.Text)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
var extraCount = Math.Max(0, todayPoints.Count - snippets.Count);
|
||||
var text = $"Letzte 24h: {string.Join(" | ", snippets)}";
|
||||
if (extraCount > 0)
|
||||
text += $" (+{extraCount} weitere)";
|
||||
|
||||
var source = todayPoints.Select(point => point.Source).Distinct(StringComparer.OrdinalIgnoreCase).Count() == 1
|
||||
? todayPoints[0].Source
|
||||
: "derived-mixed";
|
||||
|
||||
today = new AgentSummaryItemResponse(text, source, todayPoints[0].Timestamp);
|
||||
}
|
||||
|
||||
return new AgentSummaryResponse(now, today, nowUtc);
|
||||
}
|
||||
|
||||
private sealed record SummaryPoint(string Text, DateTimeOffset Timestamp, string Source);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Nexus.Api.DTOs;
|
||||
@@ -5,6 +6,7 @@ using Nexus.Api.Integrations;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/chat")]
|
||||
public class ChatController(IAgentRuntime runtime, ILogger<ChatController> logger) : ControllerBase
|
||||
|
||||
@@ -56,6 +56,10 @@ public class DashboardController(
|
||||
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||
=> await dashboardService.GetQueueAsync(ct);
|
||||
|
||||
[HttpGet("gateway")]
|
||||
public async Task<GatewayRuntimeInfo> GetGateway(CancellationToken ct)
|
||||
=> await dashboardService.GetGatewayInfoAsync(ct);
|
||||
|
||||
[HttpDelete("queue/{id}")]
|
||||
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||
{
|
||||
@@ -232,38 +236,60 @@ public class DashboardController(
|
||||
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
||||
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
// PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
|
||||
// Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
|
||||
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
|
||||
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
|
||||
// mit einer InvalidOperationException.
|
||||
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
|
||||
try
|
||||
{
|
||||
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||
|
||||
if (completed == readTask)
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var envelope = await readTask;
|
||||
if (envelope.Type == "notifications.snapshot")
|
||||
{
|
||||
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
envelope = envelope with { Payload = snapshot };
|
||||
}
|
||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||
|
||||
if (envelope.Type == "tasks.board.snapshot")
|
||||
if (completed == readTask)
|
||||
{
|
||||
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||
}
|
||||
var envelope = await readTask;
|
||||
readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
|
||||
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||
envelope,
|
||||
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||
}
|
||||
else if (await heartbeatTask)
|
||||
{
|
||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
if (envelope.Type == "notifications.snapshot")
|
||||
{
|
||||
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
envelope = envelope with { Payload = snapshot };
|
||||
}
|
||||
|
||||
if (envelope.Type == "tasks.board.snapshot")
|
||||
{
|
||||
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||
}
|
||||
|
||||
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||
envelope,
|
||||
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||
}
|
||||
else
|
||||
{
|
||||
var ticked = await heartbeatTask;
|
||||
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
if (!ticked) break;
|
||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client hat die Verbindung beendet — normal.
|
||||
}
|
||||
catch (System.Threading.Channels.ChannelClosedException)
|
||||
{
|
||||
// Subscription serverseitig geschlossen — Stream regulär beenden.
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("tasks/{id:guid}/move")]
|
||||
@@ -296,6 +322,52 @@ public class DashboardController(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Review-Aktionen (Bao/Iris) ──
|
||||
|
||||
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
|
||||
[HttpPost("tasks/{id:guid}/approve")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
|
||||
{
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
|
||||
|
||||
var result = await taskService.ApproveReviewAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
|
||||
[HttpPost("tasks/{id:guid}/request-changes")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
|
||||
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
|
||||
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
|
||||
|
||||
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||
@@ -327,19 +399,7 @@ public class DashboardController(
|
||||
|
||||
[HttpGet("tasks/{id:guid}/children")]
|
||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||
{
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
var children = board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done)
|
||||
.Where(task => task.ParentTaskId == id)
|
||||
.OrderByDescending(task => task.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
return Ok(children);
|
||||
}
|
||||
=> Ok(await taskService.GetChildTaskDtosAsync(id, ct));
|
||||
|
||||
[HttpGet("tasks/{id:guid}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Security.Claims;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Controllers;
|
||||
@@ -10,7 +12,11 @@ namespace Nexus.Api.Controllers;
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/v1/tasks")]
|
||||
public class TasksController(ITaskService taskService, IAgentService agentService, IConfiguration configuration) : ControllerBase
|
||||
public class TasksController(
|
||||
ITaskService taskService,
|
||||
IAgentService agentService,
|
||||
IConfiguration configuration,
|
||||
IActivityRepository activityRepository) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IResult> GetAll(CancellationToken ct)
|
||||
@@ -27,6 +33,7 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
}
|
||||
|
||||
[HttpGet("pending-approval")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||
{
|
||||
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||
@@ -34,9 +41,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/approve")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.ApproveAsync(id, ct);
|
||||
await WriteApprovalAuditAsync(id, "approve", result.Outcome, result.Task?.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
@@ -49,9 +58,11 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/reject")]
|
||||
[Authorize(Roles = "owner")]
|
||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await taskService.RejectAsync(id, ct);
|
||||
await WriteApprovalAuditAsync(id, "reject", result.Outcome, result.Task?.State, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||
@@ -160,4 +171,30 @@ public class TasksController(ITaskService taskService, IAgentService agentServic
|
||||
var count = await taskService.ResetStaleAsync(request.StaleHours, ct);
|
||||
return Results.Ok(new ResetStaleResponse(count));
|
||||
}
|
||||
|
||||
private async Task WriteApprovalAuditAsync(
|
||||
Guid taskId,
|
||||
string action,
|
||||
TaskOperationOutcome outcome,
|
||||
string? state,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await activityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task_approval_audit",
|
||||
Message = $"Task approval task={taskId} action={action} caller={DescribeCaller(HttpContext.User)} outcome={outcome} checkpoint={(state ?? "none")}",
|
||||
TaskId = taskId
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private static string DescribeCaller(ClaimsPrincipal user)
|
||||
{
|
||||
var subject = user.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? user.FindFirst(ClaimTypes.Email)?.Value
|
||||
?? user.Identity?.Name
|
||||
?? "unknown";
|
||||
|
||||
var role = user.FindFirst(ClaimTypes.Role)?.Value ?? "owner";
|
||||
return $"{role}:{subject}".ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
|
||||
ARG NEXUS_VERSION=dev
|
||||
ARG NEXUS_GIT_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="Nexus API" \
|
||||
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
|
||||
org.opencontainers.image.version="${NEXUS_VERSION}" \
|
||||
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.RateLimiting;
|
||||
@@ -202,6 +203,12 @@ public static class ServiceCollectionExtensions
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddMcpServer()
|
||||
.WithHttpTransport(options => options.Stateless = true)
|
||||
.WithTools<NexusMcpTools>();
|
||||
|
||||
services.AddOptions<StaleTaskRecoveryOptions>()
|
||||
.BindConfiguration(StaleTaskRecoveryOptions.SectionName);
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<LoginAttemptTracker>();
|
||||
services.AddTransient<ModelRoutingService>();
|
||||
@@ -219,6 +226,8 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<ILiveUpdateService, LiveUpdateService>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddScoped<ICalendarService, CalendarService>();
|
||||
services.AddScoped<IStaleTaskRecoveryService, StaleTaskRecoveryService>();
|
||||
services.AddHostedService<StaleTaskRecoveryBackgroundService>();
|
||||
|
||||
// ── Backend Bridge (Agent-Command-Service) ──
|
||||
services.AddScoped<ITaskBridgeService, TaskBridgeService>();
|
||||
|
||||
@@ -26,10 +26,10 @@ public static class PathSecurityHelper
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md.</summary>
|
||||
/// <summary>Validates config filename against path-traversal; must be alphanumeric .md or .json.</summary>
|
||||
public static bool IsValidConfigFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.md$");
|
||||
return System.Text.RegularExpressions.Regex.IsMatch(fileName, @"^[a-zA-Z0-9._-]+\.(md|json)$");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed record DashboardAgentInfo(
|
||||
string? Goal = null,
|
||||
string RoleBadge = "badge-slate",
|
||||
string StatusLabel = "Bereit",
|
||||
string StatusKind = "ready",
|
||||
string? StatusDetail = null,
|
||||
string? Elapsed = null,
|
||||
string? Think = null,
|
||||
string? Next = null
|
||||
@@ -97,7 +99,8 @@ public sealed record DashboardTaskDto(
|
||||
List<DashboardTaskDto>? ChildTasks = null,
|
||||
int ChildTaskCount = 0,
|
||||
int OpenChildTaskCount = 0,
|
||||
bool HasVisibleDelegation = false
|
||||
bool HasVisibleDelegation = false,
|
||||
int DoneChildTaskCount = 0
|
||||
);
|
||||
|
||||
public sealed record CreateDashboardTaskRequest(
|
||||
@@ -136,7 +139,22 @@ public sealed record UpdateDashboardTaskStatusRequest(
|
||||
|
||||
public sealed record AgentActivityEntry(
|
||||
string Time,
|
||||
string Text
|
||||
string Text,
|
||||
DateTimeOffset Timestamp,
|
||||
string Source = "gateway-session-history"
|
||||
);
|
||||
|
||||
public sealed record GatewayRuntimeInfo(
|
||||
bool Reachable,
|
||||
string BaseUrl,
|
||||
string? Version,
|
||||
string? RequiredVersion,
|
||||
bool VersionPinned,
|
||||
bool VersionMatches,
|
||||
string VersionStatus,
|
||||
DateTimeOffset CheckedAt,
|
||||
string? Message,
|
||||
string? Warning = null
|
||||
);
|
||||
|
||||
// ── Task Board DTOs ──
|
||||
@@ -166,6 +184,11 @@ public sealed record PostActivityRequest(
|
||||
string? Type = null
|
||||
);
|
||||
|
||||
public sealed record RequestChangesRequest(
|
||||
string Comment,
|
||||
string? TargetState = null
|
||||
);
|
||||
|
||||
// ── Agent Workflow DTOs ──
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
|
||||
@@ -22,5 +22,6 @@ await app.EnsureDatabaseAsync();
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
app.MapMcp();
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
@@ -3,7 +3,7 @@ using Nexus.Api.Data;
|
||||
|
||||
namespace Nexus.Api.Repositories;
|
||||
|
||||
public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
||||
public sealed class ActivityRepository(NexusDbContext db, Nexus.Api.Services.ILiveUpdateService liveUpdates) : IActivityRepository
|
||||
{
|
||||
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking().OrderByDescending(x => x.CreatedAt).Take(take).ToListAsync(ct);
|
||||
@@ -39,17 +39,35 @@ public sealed class ActivityRepository(NexusDbContext db) : IActivityRepository
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
=> db.Activity.AsNoTracking()
|
||||
.Where(x => x.Message.Contains(agentId, StringComparison.OrdinalIgnoreCase) || x.Type == "agent")
|
||||
public async Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||
{
|
||||
var candidateCount = Math.Max(take * 8, 100);
|
||||
var recent = await db.Activity.AsNoTracking()
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(take)
|
||||
.Take(candidateCount)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return recent
|
||||
.Where(x => Nexus.Api.Services.AgentActivityText.MatchesAgent(x.Message, agentId))
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||
{
|
||||
var agentIds = Nexus.Api.Services.AgentActivityText.ExtractAgentIds(activity.Message);
|
||||
activity.Message = Nexus.Api.Services.AgentActivityText.RedactForDisplay(activity.Message);
|
||||
db.Activity.Add(activity);
|
||||
await db.SaveChangesAsync(ct);
|
||||
liveUpdates.Publish("activity.created", new
|
||||
{
|
||||
activity.Id,
|
||||
activity.Type,
|
||||
activity.Message,
|
||||
activity.TaskId,
|
||||
activity.CreatedAt,
|
||||
agentIds
|
||||
}, "activity");
|
||||
return activity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public interface ITaskRepository
|
||||
ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||
Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task<bool> TryResetStaleInProgressToBacklogAsync(Guid id, DateTimeOffset staleBefore, DateTimeOffset updatedAt, CancellationToken ct = default);
|
||||
Task UpdateAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task DeleteAsync(WorkTask task, CancellationToken ct = default);
|
||||
Task<int> CountAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -27,6 +27,41 @@ public sealed class TaskRepository(NexusDbContext db) : ITaskRepository
|
||||
return task;
|
||||
}
|
||||
|
||||
public async Task<bool> TryResetStaleInProgressToBacklogAsync(
|
||||
Guid id,
|
||||
DateTimeOffset staleBefore,
|
||||
DateTimeOffset updatedAt,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!db.Database.IsRelational())
|
||||
{
|
||||
var task = await db.Tasks
|
||||
.FirstOrDefaultAsync(task => task.Id == id
|
||||
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||
&& task.UpdatedAt < staleBefore, ct);
|
||||
|
||||
if (task is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||
task.UpdatedAt = updatedAt;
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
var affectedRows = await db.Tasks
|
||||
.Where(task => task.Id == id
|
||||
&& task.State == TaskStateHelper.ToStateString(TaskState.InProgress)
|
||||
&& task.UpdatedAt < staleBefore)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(task => task.State, TaskStateHelper.ToStateString(TaskState.Backlog))
|
||||
.SetProperty(task => task.UpdatedAt, updatedAt), ct);
|
||||
|
||||
return affectedRows > 0;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(WorkTask task, CancellationToken ct = default)
|
||||
{
|
||||
task.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public static class AgentActivityText
|
||||
{
|
||||
private static readonly (Regex Pattern, string Replacement)[] InlineRedactions =
|
||||
[
|
||||
(new Regex(@"(?i)(authorization\s*:\s*bearer)\s+\S+", RegexOptions.CultureInvariant), "$1 [redacted]"),
|
||||
(new Regex(@"(?i)(x-nexus-api-key\s*:\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(api[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(token\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(password\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(secret\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(jwt\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]"),
|
||||
(new Regex(@"(?i)(private[_-]?key\s*[:=]\s*)\S+", RegexOptions.CultureInvariant), "$1[redacted]")
|
||||
];
|
||||
|
||||
private static readonly Regex[] ResidualSensitivePatterns =
|
||||
[
|
||||
new(@"(?i)bearer\s+(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
|
||||
new(@"(?i)x-nexus-api-key\s*:\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant),
|
||||
new(@"(?i)private[_-]?key\s*[:=]\s*(?!\[redacted\])\S+", RegexOptions.CultureInvariant)
|
||||
];
|
||||
|
||||
private static readonly string[] KnownActorIds =
|
||||
[
|
||||
.. AgentIdentityCatalog.DefaultConfiguredAgentIds,
|
||||
"bao",
|
||||
"nexus-system"
|
||||
];
|
||||
|
||||
public static string RedactForDisplay(string? content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return content ?? string.Empty;
|
||||
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var sanitized = lines[i];
|
||||
foreach (var (pattern, replacement) in InlineRedactions)
|
||||
{
|
||||
sanitized = pattern.Replace(sanitized, replacement);
|
||||
}
|
||||
|
||||
if (ResidualSensitivePatterns.Any(pattern => pattern.IsMatch(sanitized)))
|
||||
sanitized = "[redacted sensitive line]";
|
||||
|
||||
lines[i] = sanitized;
|
||||
}
|
||||
|
||||
return string.Join('\n', lines).Trim();
|
||||
}
|
||||
|
||||
public static bool MatchesAgent(string? content, string agentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
return false;
|
||||
|
||||
var normalized = agentId.Trim().ToLowerInvariant();
|
||||
return ExtractAgentIds(content).Contains(normalized, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string[] ExtractAgentIds(string? content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return [];
|
||||
|
||||
var matches = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var actorId in KnownActorIds)
|
||||
{
|
||||
if (BuildActorRegex(actorId).IsMatch(content))
|
||||
matches.Add(actorId);
|
||||
}
|
||||
|
||||
return matches
|
||||
.Select(actorId => actorId.ToLowerInvariant())
|
||||
.OrderBy(actorId => actorId, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static Regex BuildActorRegex(string actorId)
|
||||
=> ActorPatternCache.GetOrAdd(actorId, static key =>
|
||||
new Regex($@"(?<![a-z0-9]){Regex.Escape(key)}(?![a-z0-9])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant));
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Regex> ActorPatternCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using Nexus.Api.Helpers;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
@@ -27,6 +28,8 @@ public sealed class AgentConfigService : IAgentConfigService
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return null;
|
||||
if (!AllowedFiles.Contains(fileName))
|
||||
return null;
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
||||
@@ -37,18 +40,44 @@ public sealed class AgentConfigService : IAgentConfigService
|
||||
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
public async Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||
{
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
return null;
|
||||
var fileKind = DetermineFileKind(fileName);
|
||||
var validation = Validate(fileName, content, fileKind);
|
||||
var backup = new AgentConfigBackupResult("not_applicable", BackupCreated: false);
|
||||
var reload = CreateReloadCheck();
|
||||
if (validation.Errors.Count > 0)
|
||||
return new AgentConfigSaveAttempt(null, new AgentConfigSaveFailure("validation_failed", validation, backup, reload));
|
||||
|
||||
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||
if (!Directory.Exists(workspacePath))
|
||||
return new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"workspace_not_found",
|
||||
new AgentConfigValidationResult("failed", fileKind, ["Agent workspace is not available on this node."]),
|
||||
backup,
|
||||
reload));
|
||||
|
||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||
return null;
|
||||
return new AgentConfigSaveAttempt(
|
||||
null,
|
||||
new AgentConfigSaveFailure(
|
||||
"invalid_path",
|
||||
new AgentConfigValidationResult("failed", fileKind, ["Invalid filename or path."]),
|
||||
backup,
|
||||
reload));
|
||||
|
||||
var tempPath = safePath + ".tmp";
|
||||
var backupPath = safePath + ".bak";
|
||||
var backupCreated = false;
|
||||
try
|
||||
{
|
||||
if (File.Exists(safePath))
|
||||
{
|
||||
File.Copy(safePath, backupPath, overwrite: true);
|
||||
backupCreated = true;
|
||||
}
|
||||
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||
File.Move(tempPath, safePath!, overwrite: true);
|
||||
}
|
||||
@@ -59,6 +88,60 @@ public sealed class AgentConfigService : IAgentConfigService
|
||||
}
|
||||
|
||||
var fi = new FileInfo(safePath!);
|
||||
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
|
||||
return new AgentConfigSaveAttempt(
|
||||
new AgentConfigFileSaveResult(
|
||||
fileName,
|
||||
fi.Length,
|
||||
fi.LastWriteTimeUtc,
|
||||
new AgentConfigValidationResult("passed", fileKind, []),
|
||||
new AgentConfigBackupResult(backupCreated ? "created" : "not_applicable", backupCreated),
|
||||
CreateReloadCheck()),
|
||||
null);
|
||||
}
|
||||
|
||||
private static AgentConfigValidationResult Validate(string fileName, string content, string fileKind)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||
errors.Add("Filename is invalid.");
|
||||
else if (!AllowedFiles.Contains(fileName))
|
||||
errors.Add("File is not allowed for Mission Control editing.");
|
||||
|
||||
if (content.IndexOf('\0') >= 0)
|
||||
errors.Add("Content contains null bytes.");
|
||||
|
||||
if (content.Length > IAgentConfigService.MaxConfigFileBytes)
|
||||
errors.Add($"Content exceeds maximum size of {IAgentConfigService.MaxConfigFileBytes / 1024}KB.");
|
||||
|
||||
if (string.Equals(fileKind, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
JsonDocument.Parse(content);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
errors.Add($"JSON validation failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AgentConfigValidationResult(errors.Count == 0 ? "passed" : "failed", fileKind, errors);
|
||||
}
|
||||
|
||||
private static string DetermineFileKind(string fileName)
|
||||
{
|
||||
if (fileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
return "json";
|
||||
|
||||
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
return "markdown";
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
private static AgentConfigReloadCheckResult CreateReloadCheck()
|
||||
=> new(
|
||||
"not_supported",
|
||||
"Mission Control verified the file write locally, but agent hot reload is not available for workspace config files.");
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ public sealed class AgentModelConfigConverter : JsonConverter<AgentModelConfig>
|
||||
|
||||
if (reader.TokenType == JsonTokenType.String)
|
||||
{
|
||||
var primary = reader.GetString();
|
||||
return string.IsNullOrWhiteSpace(primary) ? null : new AgentModelConfig { Primary = primary };
|
||||
var primaryModel = reader.GetString();
|
||||
return string.IsNullOrWhiteSpace(primaryModel) ? null : new AgentModelConfig { Primary = primaryModel };
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
@@ -214,8 +214,6 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
||||
return configs
|
||||
.Where(config => !string.IsNullOrWhiteSpace(config.Id))
|
||||
.Select(config => config.Id.Trim().ToLowerInvariant())
|
||||
.DefaultIfEmpty()
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
@@ -237,7 +235,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
|
||||
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var path = configuration.GetValue<string>("AgentConfigPath")
|
||||
?? "/home/node/.openclaw/openclaw.json";
|
||||
?? "/etc/nexus/agents-sanitized.json";
|
||||
|
||||
if (!File.Exists(path))
|
||||
return BuildFallbackConfigs();
|
||||
|
||||
@@ -112,6 +112,19 @@ public sealed class DashboardService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await gateway.GetGatewayInfoAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Gateway info fetch failed");
|
||||
return new GatewayRuntimeInfo(false, "unknown", null, null, false, false, "error", DateTimeOffset.UtcNow, "Gateway nicht erreichbar", "Gateway nicht erreichbar");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||
{
|
||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -4,11 +4,38 @@ public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime Mo
|
||||
|
||||
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
|
||||
|
||||
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
|
||||
public sealed record AgentConfigValidationResult(string Status, string FileKind, IReadOnlyList<string> Errors);
|
||||
|
||||
public sealed record AgentConfigBackupResult(string Status, bool BackupCreated);
|
||||
|
||||
public sealed record AgentConfigReloadCheckResult(string Status, string Message);
|
||||
|
||||
public sealed record AgentConfigFileSaveResult(
|
||||
string FileName,
|
||||
long Size,
|
||||
DateTime ModifiedAt,
|
||||
AgentConfigValidationResult Validation,
|
||||
AgentConfigBackupResult Backup,
|
||||
AgentConfigReloadCheckResult ReloadCheck
|
||||
);
|
||||
|
||||
public sealed record AgentConfigSaveFailure(
|
||||
string Code,
|
||||
AgentConfigValidationResult Validation,
|
||||
AgentConfigBackupResult Backup,
|
||||
AgentConfigReloadCheckResult ReloadCheck
|
||||
);
|
||||
|
||||
public sealed record AgentConfigSaveAttempt(
|
||||
AgentConfigFileSaveResult? SaveResult,
|
||||
AgentConfigSaveFailure? Failure
|
||||
);
|
||||
|
||||
public interface IAgentConfigService
|
||||
{
|
||||
const int MaxConfigFileBytes = 500 * 1024;
|
||||
|
||||
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
|
||||
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
|
||||
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||
Task<AgentConfigSaveAttempt> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public interface IDashboardService
|
||||
Task<ChatResponse> SendChatAsync(string agentId, string message);
|
||||
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct);
|
||||
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
|
||||
@@ -12,6 +12,7 @@ public interface IOpenClawGatewayClient
|
||||
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||
Task<List<QueueItem>> GetQueueAsync();
|
||||
Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default);
|
||||
Task<bool> DeleteCronJobAsync(string id);
|
||||
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public interface IStaleTaskRecoveryService
|
||||
{
|
||||
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
|
||||
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
}
|
||||
@@ -33,9 +33,12 @@ public interface ITaskService
|
||||
// Task Board
|
||||
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
|
||||
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
using System.ComponentModel;
|
||||
using System.Security.Claims;
|
||||
using ModelContextProtocol.Server;
|
||||
using Nexus.Api.Controllers;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class NexusMcpTools(
|
||||
ITaskBridgeService bridge,
|
||||
IAgentService agentService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IConfiguration configuration,
|
||||
ILogger<NexusMcpTools> logger)
|
||||
{
|
||||
[McpServerTool(Name = "nexus_get_board")]
|
||||
[Description("Get the full Nexus task board grouped by canonical states.")]
|
||||
public async Task<BoardResponse> GetBoard(CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return await bridge.GetBoardAsync(ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_agent_overview")]
|
||||
[Description("Get agent workflow overview, including waiting and stale task groups.")]
|
||||
public async Task<AgentWorkflowOverview> GetAgentOverview(
|
||||
[Description("Stale threshold in hours. Defaults to 2.")]
|
||||
int staleHours = 2,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return await bridge.GetAgentOverviewAsync(TimeSpan.FromHours(Math.Max(1, staleHours)), ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_get_task")]
|
||||
[Description("Get one Nexus task by ID.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> GetTask(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return ToResponse(await bridge.GetTaskAsync(taskId, ct), "nexus_get_task");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_get_children")]
|
||||
[Description("Get child tasks for a Nexus parent task.")]
|
||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildren(Guid parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
return await bridge.GetChildTasksAsync(parentTaskId, ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_get_activity")]
|
||||
[Description("Get activity entries for a Nexus task.")]
|
||||
public async Task<IReadOnlyList<ActivityEntryDto>> GetActivity(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
var activity = await bridge.GetTaskActivityAsync(taskId, ct);
|
||||
return activity.Select(entry => new ActivityEntryDto(entry.Id, entry.Type, entry.Message, entry.CreatedAt)).ToList();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_create_task")]
|
||||
[Description("Create a top-level Nexus task.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateTask(
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var caller = await ResolveCallerAsync(ct);
|
||||
var result = await bridge.CreateTaskAsync(
|
||||
title: title,
|
||||
detail: detail,
|
||||
source: ResolveSource(caller),
|
||||
priority: priority,
|
||||
assignedTo: assignedTo ?? caller,
|
||||
ct: ct);
|
||||
|
||||
return ToResponse(result, "nexus_create_task");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_create_child_task")]
|
||||
[Description("Create a visible child task under a Nexus parent task for delegation.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> CreateChildTask(
|
||||
Guid parentTaskId,
|
||||
string title,
|
||||
string? detail = null,
|
||||
string? priority = "Normal",
|
||||
string? assignedTo = null,
|
||||
string? expectedFrom = null,
|
||||
bool startsInProgress = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var caller = await ResolveCallerAsync(ct);
|
||||
var result = await bridge.CreateChildTaskAsync(
|
||||
parentTaskId: parentTaskId,
|
||||
title: title,
|
||||
detail: detail,
|
||||
source: ResolveSource(caller),
|
||||
priority: priority,
|
||||
assignedTo: assignedTo,
|
||||
expectedFrom: expectedFrom ?? assignedTo,
|
||||
startsInProgress: startsInProgress,
|
||||
ct: ct);
|
||||
|
||||
return ToResponse(result, "nexus_create_child_task");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_update_status")]
|
||||
[Description("Update a Nexus task status. The schema only exposes canonical task states.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> UpdateStatus(
|
||||
Guid taskId,
|
||||
NexusMcpTaskState state,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var caller = await ResolveCallerAsync(ct);
|
||||
var result = await bridge.UpdateStatusAsync(taskId, ToStateString(state), caller, ct);
|
||||
return ToResponse(result, "nexus_update_status");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_append_activity")]
|
||||
[Description("Append an activity/checkpoint entry to a Nexus task.")]
|
||||
public async Task<TaskBridgeCommandResponse<ActivityEntryDto>> AppendActivity(
|
||||
Guid taskId,
|
||||
string message,
|
||||
string? type = "comment",
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
var result = await bridge.AppendActivityAsync(taskId, message, type, ct);
|
||||
return ToActivityResponse(result, "nexus_append_activity");
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "nexus_handoff")]
|
||||
[Description("Mark a task handoff to another known agent and append handoff activity.")]
|
||||
public async Task<TaskBridgeCommandResponse<DashboardTaskDto>> Handoff(
|
||||
Guid taskId,
|
||||
string targetAgent,
|
||||
string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await ResolveCallerAsync(ct);
|
||||
var result = await bridge.HandoffAsync(taskId, targetAgent, note, ct);
|
||||
return ToResponse(result, "nexus_handoff");
|
||||
}
|
||||
|
||||
private async Task<string> ResolveCallerAsync(CancellationToken ct)
|
||||
{
|
||||
var context = httpContextAccessor.HttpContext
|
||||
?? throw new UnauthorizedAccessException("MCP request context is not available.");
|
||||
|
||||
var allowedAgentIds = await agentService.GetAllowedAgentIdsAsync(ct);
|
||||
var allowedActorIds = AgentIdentityCatalog.BuildAllowedActorIds(allowedAgentIds);
|
||||
|
||||
var agentHeader = context.Request.Headers["X-Agent-Id"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(agentHeader))
|
||||
{
|
||||
var normalizedHeader = agentHeader.Trim().ToLowerInvariant();
|
||||
if (allowedActorIds.Contains(normalizedHeader))
|
||||
return normalizedHeader;
|
||||
|
||||
logger.LogWarning("MCP: ignoring unknown X-Agent-Id '{AgentId}' from {Ip}",
|
||||
normalizedHeader,
|
||||
context.Connection.RemoteIpAddress);
|
||||
}
|
||||
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var normalizedClaim = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value?.Trim().ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedClaim) && allowedActorIds.Contains(normalizedClaim))
|
||||
return normalizedClaim;
|
||||
|
||||
if (context.User.IsInRole("owner") || context.User.IsInRole("admin"))
|
||||
return "bao";
|
||||
}
|
||||
|
||||
if (RequestAuthorizationHelper.IsAuthenticatedService(context, configuration) &&
|
||||
allowedActorIds.Contains("nexus-system"))
|
||||
return "nexus-system";
|
||||
|
||||
logger.LogWarning("MCP: unauthenticated request rejected from {Ip}", context.Connection.RemoteIpAddress);
|
||||
throw new UnauthorizedAccessException("MCP tools require X-Nexus-Api-Key or a recognized X-Agent-Id.");
|
||||
}
|
||||
|
||||
private static string ResolveSource(string agentId) => agentId switch
|
||||
{
|
||||
"bao" or "nexus-system" => "bao",
|
||||
_ => agentId
|
||||
};
|
||||
|
||||
private static string ToStateString(NexusMcpTaskState state) => state switch
|
||||
{
|
||||
NexusMcpTaskState.Backlog => TaskStateHelper.ToStateString(TaskState.Backlog),
|
||||
NexusMcpTaskState.InProgress => TaskStateHelper.ToStateString(TaskState.InProgress),
|
||||
NexusMcpTaskState.Blocked => TaskStateHelper.ToStateString(TaskState.Blocked),
|
||||
NexusMcpTaskState.Done => TaskStateHelper.ToStateString(TaskState.Done),
|
||||
NexusMcpTaskState.Review => TaskStateHelper.ToStateString(TaskState.Review),
|
||||
_ => throw new InvalidEnumArgumentException(nameof(state), (int)state, typeof(NexusMcpTaskState))
|
||||
};
|
||||
|
||||
private static TaskBridgeCommandResponse<T> ToResponse<T>(TaskBridgeResult<T> result, string command) where T : class
|
||||
=> new()
|
||||
{
|
||||
Ok = result.Outcome == TaskBridgeOutcome.Success,
|
||||
Command = command,
|
||||
Data = result.Outcome == TaskBridgeOutcome.Success ? result.Data : null,
|
||||
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
|
||||
};
|
||||
|
||||
private static TaskBridgeCommandResponse<ActivityEntryDto> ToActivityResponse(
|
||||
TaskBridgeResult<ActivityEvent> result,
|
||||
string command)
|
||||
=> new()
|
||||
{
|
||||
Ok = result.Outcome == TaskBridgeOutcome.Success,
|
||||
Command = command,
|
||||
Data = result.Data is null
|
||||
? null
|
||||
: new ActivityEntryDto(result.Data.Id, result.Data.Type, result.Data.Message, result.Data.CreatedAt),
|
||||
Error = result.Outcome == TaskBridgeOutcome.Success ? null : result.Error ?? result.Outcome.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public enum NexusMcpTaskState
|
||||
{
|
||||
Backlog,
|
||||
InProgress,
|
||||
Blocked,
|
||||
Done,
|
||||
Review
|
||||
}
|
||||
@@ -8,6 +8,14 @@ namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
|
||||
{
|
||||
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(15);
|
||||
|
||||
private static readonly string[] SensitiveMarkers =
|
||||
[
|
||||
"api_key", "apikey", "api-key", "authorization", "bearer ", "password",
|
||||
"token", "secret", "x-nexus-api-key", "jwt", "private_key"
|
||||
];
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
@@ -115,7 +123,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
)
|
||||
};
|
||||
|
||||
// Load agent IDs from openclaw.json config
|
||||
// Load agent IDs from sanitized agents config (no secrets)
|
||||
var agentIds = LoadAgentIdsFromConfig();
|
||||
|
||||
var agents = new List<DashboardAgentInfo>();
|
||||
@@ -139,6 +147,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
// 3. Extract activity from session_status
|
||||
var isActive = false;
|
||||
string? currentTask = null;
|
||||
var statusText = status?["status"]?.GetValue<string>();
|
||||
if (status is not null)
|
||||
{
|
||||
// Check explicit isActive field
|
||||
@@ -149,7 +158,6 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
isActive = string.Equals(activeVal.GetValue<string>(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Fall back to status text
|
||||
var statusText = status["status"]?.GetValue<string>();
|
||||
if (!isActive && statusText is not null)
|
||||
isActive = string.Equals(statusText, "active", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(statusText, "running", StringComparison.OrdinalIgnoreCase);
|
||||
@@ -191,6 +199,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
// 8. Calculate workload from queue items
|
||||
var workload = CalculateAgentWorkload(id, queueItems);
|
||||
|
||||
var statusKind = DeriveStatusKind(status, isActive);
|
||||
var statusDetail = DeriveStatusDetail(status, statusKind);
|
||||
|
||||
agents.Add(new DashboardAgentInfo(
|
||||
Id: id,
|
||||
Name: string.IsNullOrWhiteSpace(name) ? DeriveRole(id) : name,
|
||||
@@ -204,7 +215,9 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
Workload: workload,
|
||||
Goal: goal,
|
||||
RoleBadge: DeriveRoleBadge(id),
|
||||
StatusLabel: DeriveStatusLabel(isActive, status),
|
||||
StatusLabel: DeriveStatusLabel(statusKind, isActive, statusText),
|
||||
StatusKind: statusKind,
|
||||
StatusDetail: statusDetail,
|
||||
Elapsed: FormatElapsed(status),
|
||||
Think: null,
|
||||
Next: DeriveNext(isActive, currentTask)
|
||||
@@ -214,7 +227,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads agent IDs from the OpenClaw config file (openclaw.json).
|
||||
/// Loads agent IDs from the sanitized agents config (no secrets).
|
||||
/// Falls back to the known list if the config file is unavailable.
|
||||
/// </summary>
|
||||
private List<string> LoadAgentIdsFromConfig()
|
||||
@@ -222,7 +235,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
try
|
||||
{
|
||||
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||
?? "/home/node/.openclaw/openclaw.json";
|
||||
?? "/etc/nexus/agents-sanitized.json";
|
||||
|
||||
if (!System.IO.File.Exists(configPath))
|
||||
return GetDefaultAgentIds();
|
||||
@@ -692,6 +705,72 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GatewayRuntimeInfo> GetGatewayInfoAsync(CancellationToken ct = default)
|
||||
{
|
||||
var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "unknown";
|
||||
var requiredVersion = NormalizeOptional(configuration["Integrations:OpenClaw:RequiredVersion"]);
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "/health");
|
||||
ApplyAuth(request);
|
||||
using var response = await httpClient.SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
string? version = response.Headers.TryGetValues("X-OpenClaw-Version", out var headerValues)
|
||||
? headerValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(version) && !string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
version = TryGetString(root, "version")
|
||||
?? TryGetString(root, "gatewayVersion")
|
||||
?? TryGetString(root, "openclawVersion");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Health endpoint may be plain text.
|
||||
}
|
||||
}
|
||||
|
||||
version = NormalizeOptional(version);
|
||||
var pinned = requiredVersion is not null;
|
||||
var versionStatus = DetermineVersionStatus(response.IsSuccessStatusCode, version, requiredVersion);
|
||||
var matches = versionStatus is "matched" or "unpinned";
|
||||
var message = BuildGatewayMessage(response.IsSuccessStatusCode, versionStatus, requiredVersion);
|
||||
var warning = BuildGatewayWarning(response.IsSuccessStatusCode, versionStatus, version, requiredVersion, null);
|
||||
|
||||
return new GatewayRuntimeInfo(
|
||||
response.IsSuccessStatusCode,
|
||||
baseUrl,
|
||||
version,
|
||||
requiredVersion,
|
||||
pinned,
|
||||
response.IsSuccessStatusCode && matches,
|
||||
versionStatus,
|
||||
DateTimeOffset.UtcNow,
|
||||
message,
|
||||
warning);
|
||||
}
|
||||
catch
|
||||
{
|
||||
var warning = BuildGatewayWarning(false, "error", null, requiredVersion, "Gateway nicht erreichbar");
|
||||
return new GatewayRuntimeInfo(
|
||||
false,
|
||||
baseUrl,
|
||||
null,
|
||||
requiredVersion,
|
||||
requiredVersion is not null,
|
||||
false,
|
||||
"error",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Gateway nicht erreichbar",
|
||||
warning);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCronJobAsync(string id)
|
||||
{
|
||||
try
|
||||
@@ -980,13 +1059,14 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
continue;
|
||||
|
||||
// Truncate content to first 200 chars for compact display
|
||||
var text = msg.Content.Length > 200
|
||||
? msg.Content[..200] + "…"
|
||||
: msg.Content;
|
||||
var redacted = AgentActivityText.RedactForDisplay(msg.Content);
|
||||
var text = redacted.Length > 200
|
||||
? redacted[..200] + "…"
|
||||
: redacted;
|
||||
var ts = ParseTimestamp(msg.Timestamp);
|
||||
var timeAgo = FormatTimeAgo(ts);
|
||||
|
||||
entries.Add(new AgentActivityEntry(timeAgo, text));
|
||||
entries.Add(new AgentActivityEntry(timeAgo, text, ts));
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -1005,7 +1085,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
try
|
||||
{
|
||||
var configPath = configuration.GetValue<string>("AgentConfigPath")
|
||||
?? "/home/node/.openclaw/openclaw.json";
|
||||
?? "/etc/nexus/agents-sanitized.json";
|
||||
|
||||
if (!System.IO.File.Exists(configPath))
|
||||
return GetDefaultModels();
|
||||
@@ -1076,25 +1156,83 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
_ => "badge-slate"
|
||||
};
|
||||
|
||||
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
|
||||
private static string DeriveStatusLabel(string statusKind, bool isActive, string? statusText)
|
||||
{
|
||||
if (!isActive) return "Bereit";
|
||||
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
|
||||
return statusText switch
|
||||
return statusKind switch
|
||||
{
|
||||
"thinking" or "think" => "Plant",
|
||||
"blocked" or "block" => "Blockiert",
|
||||
_ => "Arbeitet"
|
||||
"connected" => isActive ? "Arbeitet" : "Verbunden",
|
||||
"thinking" => "Plant",
|
||||
"blocked" => "Blockiert",
|
||||
"stale" => "Stale",
|
||||
"error" => "Fehler",
|
||||
"unsupported" => "Unsupported",
|
||||
"ready" => "Bereit",
|
||||
_ => statusText?.ToLowerInvariant() switch
|
||||
{
|
||||
"thinking" or "think" => "Plant",
|
||||
"blocked" or "block" => "Blockiert",
|
||||
_ => isActive ? "Arbeitet" : "Bereit"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string DeriveStatusKind(JsonNode? status, bool isActive)
|
||||
{
|
||||
if (status is null)
|
||||
return "error";
|
||||
|
||||
var statusText = status["status"]?.GetValue<string>()?.Trim();
|
||||
var errorText = status["error"]?.GetValue<string>()?.Trim()
|
||||
?? status["message"]?.GetValue<string>()?.Trim();
|
||||
var normalized = statusText?.ToLowerInvariant();
|
||||
var detail = $"{statusText} {errorText}".Trim().ToLowerInvariant();
|
||||
|
||||
if (detail.Contains("unsupported", StringComparison.Ordinal))
|
||||
return "unsupported";
|
||||
if (!string.IsNullOrWhiteSpace(errorText)
|
||||
|| normalized is "error" or "failed" or "offline" or "disconnected" or "unreachable")
|
||||
return "error";
|
||||
if (normalized is "blocked" or "block")
|
||||
return "blocked";
|
||||
if (normalized is "thinking" or "think")
|
||||
return "thinking";
|
||||
|
||||
var lastActivity = TryGetStatusTimestamp(status);
|
||||
if (lastActivity is not null && DateTimeOffset.UtcNow - lastActivity.Value > StaleThreshold)
|
||||
return "stale";
|
||||
|
||||
if (isActive || normalized is "active" or "running" or "connected" or "online")
|
||||
return "connected";
|
||||
|
||||
return "ready";
|
||||
}
|
||||
|
||||
private static string? DeriveStatusDetail(JsonNode? status, string statusKind)
|
||||
{
|
||||
if (status is null)
|
||||
return "Gateway-Status nicht abrufbar";
|
||||
|
||||
var message = NormalizeOptional(status["message"]?.GetValue<string>())
|
||||
?? NormalizeOptional(status["error"]?.GetValue<string>())
|
||||
?? NormalizeOptional(status["detail"]?.GetValue<string>());
|
||||
|
||||
if (message is not null)
|
||||
return message;
|
||||
|
||||
return statusKind switch
|
||||
{
|
||||
"stale" => FormatStaleDetail(TryGetStatusTimestamp(status)),
|
||||
"unsupported" => "Session meldet einen nicht unterstützten Zustand",
|
||||
"error" => "Session-Status konnte nicht gelesen werden",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatElapsed(JsonNode? status)
|
||||
{
|
||||
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
||||
?? status?["lastMessage"]?.GetValue<string>();
|
||||
var lastActivity = TryGetStatusTimestamp(status);
|
||||
if (lastActivity is null) return null;
|
||||
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
|
||||
var diff = DateTimeOffset.UtcNow - ts;
|
||||
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
|
||||
if (diff.TotalSeconds < 60) return $"{(int)diff.TotalSeconds}s";
|
||||
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m";
|
||||
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h";
|
||||
@@ -1120,4 +1258,96 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
||||
"main" => "Assistant",
|
||||
_ => "Custom"
|
||||
};
|
||||
|
||||
private static string? TryGetString(JsonElement root, string property)
|
||||
=> root.ValueKind == JsonValueKind.Object
|
||||
&& root.TryGetProperty(property, out var value)
|
||||
&& value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
|
||||
public static string RedactSensitiveText(string content)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return content;
|
||||
|
||||
var lines = content.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var lower = lines[i].ToLowerInvariant();
|
||||
if (SensitiveMarkers.Any(marker => lower.Contains(marker, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
lines[i] = "[redacted sensitive line]";
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static string? NormalizeOptional(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static DateTimeOffset? TryGetStatusTimestamp(JsonNode? status)
|
||||
{
|
||||
var raw = status?["lastActivity"]?.GetValue<string>()
|
||||
?? status?["lastMessage"]?.GetValue<string>()
|
||||
?? status?["updatedAt"]?.GetValue<string>();
|
||||
return DateTimeOffset.TryParse(raw, out var ts) ? ts : null;
|
||||
}
|
||||
|
||||
private static string DetermineVersionStatus(bool reachable, string? version, string? requiredVersion)
|
||||
{
|
||||
if (!reachable)
|
||||
return "error";
|
||||
if (requiredVersion is null)
|
||||
return version is null ? "unknown" : "unpinned";
|
||||
if (version is null)
|
||||
return "missing";
|
||||
return string.Equals(version, requiredVersion, StringComparison.OrdinalIgnoreCase) ? "matched" : "drift";
|
||||
}
|
||||
|
||||
private static string BuildGatewayMessage(bool reachable, string versionStatus, string? requiredVersion)
|
||||
{
|
||||
if (!reachable)
|
||||
return "Gateway nicht erreichbar";
|
||||
|
||||
return versionStatus switch
|
||||
{
|
||||
"matched" => "Gateway erreichbar und Version gepinnt",
|
||||
"missing" => requiredVersion is null
|
||||
? "Gateway erreichbar"
|
||||
: $"Gateway erreichbar, aber Versionspin {requiredVersion} nicht nachweisbar",
|
||||
"drift" => "Gateway erreichbar, aber Version weicht vom Pin ab",
|
||||
"unpinned" => "Gateway erreichbar",
|
||||
"unknown" => "Gateway erreichbar, Version nicht erkannt",
|
||||
_ => "Gateway erreichbar"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? BuildGatewayWarning(bool reachable, string versionStatus, string? version, string? requiredVersion, string? fallback)
|
||||
{
|
||||
if (!reachable)
|
||||
return fallback ?? "Gateway nicht erreichbar";
|
||||
|
||||
return versionStatus switch
|
||||
{
|
||||
"missing" when requiredVersion is not null => $"Gateway meldet keine Version; erwartet wird {requiredVersion}.",
|
||||
"drift" when requiredVersion is not null => $"Gateway meldet {version ?? "unknown"} statt {requiredVersion}.",
|
||||
"unknown" => "Gateway-Version konnte nicht erkannt werden.",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatStaleDetail(DateTimeOffset? lastActivity)
|
||||
{
|
||||
if (lastActivity is null)
|
||||
return "Letzte Aktivität ist veraltet";
|
||||
|
||||
var diff = DateTimeOffset.UtcNow - lastActivity.Value;
|
||||
if (diff.TotalMinutes < 60)
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalMinutes}m";
|
||||
if (diff.TotalHours < 24)
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalHours}h";
|
||||
return $"Keine neue Aktivität seit {(int)diff.TotalDays}d";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class StaleTaskRecoveryBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptionsMonitor<StaleTaskRecoveryOptions> optionsMonitor,
|
||||
ILogger<StaleTaskRecoveryBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
|
||||
if (flaggedCount > 0)
|
||||
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Stale task recovery run failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(optionsMonitor.CurrentValue.GetInterval(), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<int> RunWatchdogOnceAsync(CancellationToken ct = default)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
|
||||
return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class StaleTaskRecoveryOptions
|
||||
{
|
||||
public const string SectionName = "TaskRecovery";
|
||||
|
||||
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
|
||||
public int StalledMinutes { get; set; } = 40;
|
||||
|
||||
/// <summary>Prüfintervall des Watchdogs.</summary>
|
||||
public int IntervalMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
|
||||
public int StaleHours { get; set; } = 2;
|
||||
|
||||
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
|
||||
|
||||
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
|
||||
|
||||
public TimeSpan GetInterval() => TimeSpan.FromMinutes(Math.Max(1, IntervalMinutes));
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Models;
|
||||
using Nexus.Api.Repositories;
|
||||
|
||||
namespace Nexus.Api.Services;
|
||||
|
||||
public sealed class StaleTaskRecoveryService(
|
||||
ITaskRepository taskRepository,
|
||||
IActivityRepository activityRepository,
|
||||
ILiveUpdateService liveUpdateService,
|
||||
INotificationService notificationService) : IStaleTaskRecoveryService
|
||||
{
|
||||
private const string StalledActivityType = "stalled";
|
||||
|
||||
/// <summary>
|
||||
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
|
||||
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
|
||||
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
|
||||
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
|
||||
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
|
||||
/// </summary>
|
||||
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var threshold = now - stalledThreshold;
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
|
||||
var inProgress = allTasks
|
||||
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (inProgress.Count == 0)
|
||||
return 0;
|
||||
|
||||
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
|
||||
var activityByTask = activities
|
||||
.Where(a => a.TaskId.HasValue)
|
||||
.GroupBy(a => a.TaskId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
|
||||
|
||||
var flaggedCount = 0;
|
||||
|
||||
foreach (var task in inProgress)
|
||||
{
|
||||
activityByTask.TryGetValue(task.Id, out var taskActivity);
|
||||
var latest = taskActivity?.FirstOrDefault();
|
||||
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
|
||||
|
||||
if (lastProgressAt >= threshold)
|
||||
continue;
|
||||
|
||||
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
|
||||
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var silentFor = now - lastProgressAt;
|
||||
await activityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = StalledActivityType,
|
||||
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"task_stalled",
|
||||
$"Task hängt: {task.Title}",
|
||||
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
flaggedCount++;
|
||||
}
|
||||
|
||||
if (flaggedCount > 0)
|
||||
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
|
||||
|
||||
return flaggedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
|
||||
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
|
||||
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
|
||||
/// </summary>
|
||||
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
var staleTasks = await GetStaleTasksAsync(threshold, ct);
|
||||
if (staleTasks.Count == 0)
|
||||
return 0;
|
||||
|
||||
var latestActivityByTaskId = await GetLatestActivityByTaskIdAsync(staleTasks.Select(task => task.Id), ct);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var resetCount = 0;
|
||||
|
||||
foreach (var task in staleTasks)
|
||||
{
|
||||
var currentTask = await taskRepository.GetByIdAsync(task.Id, ct);
|
||||
if (currentTask is null || !IsStaleInProgress(currentTask, threshold))
|
||||
continue;
|
||||
|
||||
latestActivityByTaskId.TryGetValue(currentTask.Id, out var lastActivityAt);
|
||||
var message = BuildActivityMessage(currentTask, staleThreshold, now, lastActivityAt);
|
||||
var updated = await taskRepository.TryResetStaleInProgressToBacklogAsync(
|
||||
currentTask.Id,
|
||||
threshold,
|
||||
now,
|
||||
ct);
|
||||
if (!updated)
|
||||
continue;
|
||||
|
||||
await activityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = message,
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
resetCount++;
|
||||
}
|
||||
|
||||
if (resetCount > 0)
|
||||
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
|
||||
|
||||
return resetCount;
|
||||
}
|
||||
|
||||
private async Task<List<WorkTask>> GetStaleTasksAsync(DateTimeOffset threshold, CancellationToken ct)
|
||||
{
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
|
||||
return allTasks
|
||||
.Where(task => IsStaleInProgress(task, threshold))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsStaleInProgress(WorkTask task, DateTimeOffset threshold)
|
||||
=> string.Equals(task.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase)
|
||||
&& task.UpdatedAt < threshold;
|
||||
|
||||
private async Task<Dictionary<Guid, DateTimeOffset>> GetLatestActivityByTaskIdAsync(
|
||||
IEnumerable<Guid> taskIds,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var activities = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
|
||||
|
||||
return activities
|
||||
.Where(activity => activity.TaskId.HasValue)
|
||||
.GroupBy(activity => activity.TaskId!.Value)
|
||||
.ToDictionary(group => group.Key, group => group.Max(activity => activity.CreatedAt));
|
||||
}
|
||||
|
||||
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
|
||||
return TaskService.BuildMasterBoard(allTasks, activity);
|
||||
}
|
||||
|
||||
private static string BuildActivityMessage(
|
||||
WorkTask task,
|
||||
TimeSpan staleThreshold,
|
||||
DateTimeOffset now,
|
||||
DateTimeOffset? lastActivityAt)
|
||||
{
|
||||
var staleAge = now - task.UpdatedAt;
|
||||
var details = new List<string>
|
||||
{
|
||||
"reason=stale-recovery",
|
||||
"previous status In progress",
|
||||
$"stale reference {now:O}",
|
||||
$"stale age {FormatDuration(staleAge)}",
|
||||
$"threshold {FormatDuration(staleThreshold)}"
|
||||
};
|
||||
|
||||
if (lastActivityAt.HasValue)
|
||||
details.Add($"last activity {lastActivityAt.Value:O}");
|
||||
|
||||
details.Add($"last update {task.UpdatedAt:O}");
|
||||
details.Add("new status Backlog");
|
||||
|
||||
return $"Task \"{task.Title}\" reset from In progress to Backlog by stale recovery ({string.Join("; ", details)})";
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
{
|
||||
if (duration.TotalHours >= 1)
|
||||
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
|
||||
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
|
||||
}
|
||||
}
|
||||
@@ -214,13 +214,7 @@ public sealed class TaskBridgeService(
|
||||
|
||||
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
|
||||
Guid parentTaskId, CancellationToken ct = default)
|
||||
{
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
return FlattenBoard(board)
|
||||
.Where(task => task.ParentTaskId == parentTaskId)
|
||||
.OrderByDescending(task => task.UpdatedAt)
|
||||
.ToList();
|
||||
}
|
||||
=> await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(
|
||||
Guid taskId, CancellationToken ct = default)
|
||||
@@ -250,13 +244,6 @@ public sealed class TaskBridgeService(
|
||||
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||
}
|
||||
|
||||
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
|
||||
=> board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done);
|
||||
|
||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||
|
||||
+115
-30
@@ -11,7 +11,8 @@ public sealed class TaskService(
|
||||
INotificationService notificationService,
|
||||
IAgentService agentService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ILiveUpdateService liveUpdateService) : ITaskService
|
||||
ILiveUpdateService liveUpdateService,
|
||||
IStaleTaskRecoveryService staleTaskRecoveryService) : ITaskService
|
||||
{
|
||||
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||
=> await taskRepo.GetAllAsync(ct);
|
||||
@@ -421,6 +422,19 @@ public sealed class TaskService(
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return BuildMasterBoard(all, activity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
|
||||
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
|
||||
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
|
||||
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
|
||||
/// damit nichts unsichtbar wird.
|
||||
/// </summary>
|
||||
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
|
||||
{
|
||||
var ids = all.Select(t => t.Id).ToHashSet();
|
||||
|
||||
var offen = new List<DashboardTaskDto>();
|
||||
var inProgress = new List<DashboardTaskDto>();
|
||||
@@ -430,7 +444,10 @@ public sealed class TaskService(
|
||||
|
||||
foreach (var task in all)
|
||||
{
|
||||
var dto = MapToDtoWithChildren(task, all, activity);
|
||||
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
|
||||
if (!isMaster) continue;
|
||||
|
||||
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
case "backlog": offen.Add(dto); break;
|
||||
@@ -489,36 +506,86 @@ public sealed class TaskService(
|
||||
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
task.ExpectedFrom = null;
|
||||
return await UpdateTaskStatusInternalAsync(
|
||||
task,
|
||||
TaskStateHelper.ToStateString(TaskState.Done),
|
||||
caller,
|
||||
"review",
|
||||
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
|
||||
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
|
||||
?? TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
|
||||
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
target = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
|
||||
var trimmed = comment.Trim();
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "review_changes_requested",
|
||||
Message = $"Änderung angefordert von {caller}: {trimmed}",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
task.ExpectedFrom = "iris";
|
||||
var result = await UpdateTaskStatusInternalAsync(
|
||||
task, target, caller, "review",
|
||||
$"Review zurückgegeben von {caller} → {target}", ct);
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"task_changes_requested",
|
||||
$"Änderung angefordert: {task.Title}",
|
||||
trimmed,
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedHours = Math.Max(1, staleHours);
|
||||
return ResetStaleInProgressTasksAsync(TimeSpan.FromHours(normalizedHours), ct);
|
||||
}
|
||||
|
||||
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var all = await taskRepo.GetAllAsync(ct);
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
var staleTasks = all.Where(t => string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) && t.UpdatedAt < threshold).ToList();
|
||||
|
||||
foreach (var task in staleTasks)
|
||||
{
|
||||
var prevState = task.State;
|
||||
task.State = "Backlog";
|
||||
await taskRepo.UpdateAsync(task, ct);
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "task",
|
||||
Message = $"Task \"{task.Title}\" reset from {prevState} to Backlog (stale)",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
}
|
||||
|
||||
if (staleTasks.Count > 0)
|
||||
await PublishBoardSnapshotAsync(ct);
|
||||
|
||||
return staleTasks.Count;
|
||||
}
|
||||
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
=> staleTaskRecoveryService.ResetStaleInProgressTasksAsync(staleThreshold, ct);
|
||||
|
||||
public async Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -528,28 +595,46 @@ public sealed class TaskService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
|
||||
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
|
||||
/// </summary>
|
||||
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return all.Where(t => t.ParentTaskId == parentId)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var all = await activityRepo.GetRecentAsync(100, ct);
|
||||
return all.Where(e => e.TaskId == taskId).ToList();
|
||||
}
|
||||
|
||||
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
|
||||
private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||
{
|
||||
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
|
||||
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
|
||||
var childDtos = includeChildren
|
||||
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
||||
: null;
|
||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var dto = MapToDtoWithActivity(task, activity, allTasks);
|
||||
return dto with
|
||||
{
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childDtos.Count,
|
||||
ChildTaskCount = childTasks.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
||||
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Integrations": {
|
||||
"OpenClaw": {
|
||||
"BaseUrl": "http://127.0.0.1:18789",
|
||||
"RequiredVersion": "",
|
||||
"Token": "",
|
||||
"Password": ""
|
||||
},
|
||||
@@ -21,5 +22,10 @@
|
||||
"AccessTokenExpirationMinutes": 15,
|
||||
"RefreshTokenExpirationDays": 7
|
||||
},
|
||||
"TaskRecovery": {
|
||||
"StalledMinutes": 40,
|
||||
"IntervalMinutes": 10,
|
||||
"StaleHours": 2
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
+20
-4
@@ -2,6 +2,15 @@ name: nexus
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
# WAL-Archivierung bleibt deaktiviert, bis ein verwaltetes Off-Server-Ziel
|
||||
# mit Retention und Restore-Test existiert. Ein lokales Endlosarchiv ist
|
||||
# kein Backup und kann bei Fehlern pg_wal ungebremst wachsen lassen.
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- archive_mode=off
|
||||
- -c
|
||||
- archive_command=
|
||||
restart: always
|
||||
deploy:
|
||||
resources:
|
||||
@@ -31,6 +40,9 @@ services:
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
args:
|
||||
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
|
||||
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
|
||||
restart: always
|
||||
deploy:
|
||||
resources:
|
||||
@@ -47,16 +59,17 @@ services:
|
||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
|
||||
# Initial owner password is generated once at first seed and then lives only in the DB.
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||
Admin__ResetToken: ${Admin__ResetToken:-}
|
||||
NexusApiKey: ${NEXUS_API_KEY:-}
|
||||
AgentConfigPath: /etc/nexus/agents-sanitized.json
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
||||
@@ -65,7 +78,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
volumes:
|
||||
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
|
||||
- /home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json:/etc/nexus/agents-sanitized.json:ro
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
|
||||
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
|
||||
@@ -83,6 +96,9 @@ services:
|
||||
web:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
NEXUS_VERSION: ${NEXUS_VERSION:-dev}
|
||||
NEXUS_GIT_SHA: ${NEXUS_GIT_SHA:-unknown}
|
||||
restart: always
|
||||
deploy:
|
||||
resources:
|
||||
@@ -100,7 +116,7 @@ services:
|
||||
- "127.0.0.1:18880:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||
|
||||
@@ -68,8 +68,8 @@ Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren
|
||||
│ │
|
||||
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
|
||||
└──────────────┬──────────────────────────────┬────────────────────┘
|
||||
│ host.docker.internal:18789 │
|
||||
│ (Gateway loopback/lan) │
|
||||
│ openclaw-gateway-bao:18789 │
|
||||
│ (internes Docker-DNS) │
|
||||
▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ OpenClaw Gateway Container │ │
|
||||
@@ -199,7 +199,7 @@ Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
|
||||
```
|
||||
POST /api/v1/operations/snapshot
|
||||
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
|
||||
→ POST http://host.docker.internal:18789/tools/invoke
|
||||
→ POST http://openclaw-gateway-bao:18789/tools/invoke
|
||||
Authorization: Bearer <Gateway-Password>
|
||||
```
|
||||
|
||||
@@ -220,7 +220,7 @@ POST /api/v1/operations/snapshot
|
||||
|
||||
### 5.2 Docker-Netzwerk & Gateway-Bind
|
||||
|
||||
**Aktuelles Problem:**
|
||||
**Aktueller Stand (2026-07-09):**
|
||||
```
|
||||
compose.yaml:
|
||||
api:
|
||||
@@ -228,32 +228,27 @@ compose.yaml:
|
||||
- host.docker.internal:host-gateway
|
||||
networks:
|
||||
- nexus
|
||||
- openclaw_default ← API-Container ist im Gateway-Netzwerk
|
||||
- openclaw_default
|
||||
|
||||
Gateway-Konfiguration:
|
||||
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER
|
||||
gateway.bind: "lan"
|
||||
|
||||
Nexus-Konfiguration:
|
||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||
```
|
||||
|
||||
**Ergebnis:**
|
||||
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt
|
||||
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback
|
||||
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt
|
||||
- Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
|
||||
- Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
|
||||
- Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
|
||||
|
||||
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):**
|
||||
```json5
|
||||
// openclaw.json
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan" // war "loopback"
|
||||
}
|
||||
}
|
||||
```
|
||||
Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
|
||||
|
||||
Alternativ: API-Container über Gateway-Container-Namen ansprechen:
|
||||
Produktive Einstellung:
|
||||
```yaml
|
||||
Integrations__OpenClaw__BaseUrl: http://openclaw_gateway:18789
|
||||
Integrations__OpenClaw__BaseUrl: http://openclaw-gateway-bao:18789
|
||||
```
|
||||
(Vorausgesetzt der Gateway-Container heißt `openclaw_gateway` und ist im `openclaw_default` Netzwerk)
|
||||
Beide Container müssen Mitglied im `openclaw_default`-Netzwerk sein.
|
||||
|
||||
### 5.3 MCP-artige Integration: Bewertung
|
||||
|
||||
|
||||
@@ -290,30 +290,30 @@ The Nexus compose.yaml already includes the full integration infrastructure:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
environment:
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||
networks:
|
||||
- nexus
|
||||
- openclaw_default
|
||||
```
|
||||
|
||||
The API container:
|
||||
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
|
||||
- Has `extra_hosts` configured for `host.docker.internal`
|
||||
- Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
|
||||
- Does not depend on a published host port for the Gateway
|
||||
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
|
||||
|
||||
### Known Issue: Gateway Bind = loopback
|
||||
### Resolved Routing Issue (2026-07-09)
|
||||
|
||||
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
|
||||
The old `host.docker.internal:18789` route was unreachable because no usable host port was published. The Gateway is now reached directly by its container DNS name.
|
||||
|
||||
| Scenario | Works? | Why |
|
||||
|----------|--------|-----|
|
||||
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
|
||||
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
|
||||
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
|
||||
| `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
|
||||
| `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
|
||||
|
||||
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
|
||||
The Gateway must listen on its container interface (`gateway.bind: "lan"`):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -325,8 +325,8 @@ The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it onl
|
||||
|
||||
**Test command (from Nexus API container):**
|
||||
```bash
|
||||
curl -s http://host.docker.internal:18789/health
|
||||
# Expected: 200 if gateway bind is lan/container IP is reachable
|
||||
curl -s http://openclaw-gateway-bao:18789/
|
||||
# Expected: HTTP 200 from inside nexus-api-1
|
||||
```
|
||||
|
||||
### Required .env Vars for Nexus
|
||||
|
||||
@@ -10,6 +10,7 @@ Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
|
||||
- **Sub-Agenten** als ausführende Spezialisten
|
||||
- **OpenClaw** als Agent-Runtime
|
||||
- **Nexus Task Board** als sichtbare Aufgabenquelle
|
||||
- **MCP `/mcp`** als Agent Data Plane fuer Board-Operationen
|
||||
|
||||
---
|
||||
|
||||
@@ -24,6 +25,8 @@ Das bedeutet:
|
||||
- **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten
|
||||
- **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership
|
||||
- **OpenClaw** = Ausführungspfad für Agentenarbeit
|
||||
- **MCP** = bevorzugter Agentenpfad zu Nexus; `/api/bridge` bleibt
|
||||
kompatible interne Fassade, `/api/dashboard` bleibt UI/Admin
|
||||
|
||||
---
|
||||
|
||||
@@ -57,7 +60,7 @@ flowchart LR
|
||||
Iris -->|delegiert konkrete Arbeit| OC
|
||||
OC -->|führt Agenten-Task aus| Agents
|
||||
Iris -->|legt Child-Tasks an| Board
|
||||
Agents -->|arbeiten gegen Child-Tasks| Board
|
||||
Agents -->|MCP Tools /mcp| Board
|
||||
Agents -->|liefern Ergebnis / melden Blocker| Iris
|
||||
Iris -->|integriert Ergebnis| Board
|
||||
Board -->|Review für Bao| Bao
|
||||
@@ -89,6 +92,13 @@ flowchart LR
|
||||
- liefert Nachrichten, Status und Arbeitsergebnisse zurück
|
||||
- ersetzt nicht das Board als Aufgabenwahrheit
|
||||
|
||||
### MCP Agent Data Plane
|
||||
- stellt `nexus_get_board`, `nexus_agent_overview`, Task-, Child-,
|
||||
Activity-, Status-, Checkpoint- und Handoff-Tools bereit
|
||||
- nutzt nur kanonische States: `Backlog`, `In progress`, `Blocked`,
|
||||
`Done`, `Review`
|
||||
- ist Fassade ueber `ITaskBridgeService`, keine zweite Board-Domaenenlogik
|
||||
|
||||
### Nexus Task Board
|
||||
- ist die **sichtbare operative Quelle** für Aufgaben
|
||||
- zeigt Parent-Task, Child-Tasks, Ownership und Status
|
||||
@@ -315,6 +325,11 @@ Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
|
||||
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
|
||||
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
|
||||
- UI und Doku müssen dieselbe Sprache sprechen
|
||||
- Mission-Control-Gateway-Daten bleiben read-only im Browser: Nexus proxyt Status,
|
||||
Version und redigierte Activity; Gateway-Token und direkte Gateway-URLs bleiben
|
||||
im Backend.
|
||||
- Config-Writes sind Bao/Owner-only, legen vor dem Austausch ein `.bak` an und
|
||||
schreiben einen `config_audit` Activity-Eintrag.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ COPY . .
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
ARG NEXUS_VERSION=dev
|
||||
ARG NEXUS_GIT_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="Nexus Web" \
|
||||
org.opencontainers.image.source="https://git.noveria.net/bao/nexus" \
|
||||
org.opencontainers.image.version="${NEXUS_VERSION}" \
|
||||
org.opencontainers.image.revision="${NEXUS_GIT_SHA}"
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
||||
@@ -5,6 +5,15 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Kompression für Bundle + API-JSON (Board-Payload ~100 KB → wenige KB).
|
||||
# text/event-stream bewusst NICHT in gzip_types: gzip würde den SSE-Stream puffern.
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_types application/json application/javascript text/css text/javascript image/svg+xml;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
+8
-146
@@ -1,152 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Activity } from '@lucide/vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useOperationsStore } from './stores/operations'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import AppHeader from './components/layout/AppHeader.vue'
|
||||
import ModuleView from './components/ModuleView.vue'
|
||||
/**
|
||||
* App — nur noch Router-Einstieg + Toasts.
|
||||
* Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
|
||||
* und umschließt alle Seiten außer dem Login.
|
||||
*/
|
||||
import { RouterView } from 'vue-router'
|
||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||
|
||||
const store = useOperationsStore()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeView = computed(() => {
|
||||
if (route.name === 'Settings') return 'Settings'
|
||||
if (route.name === 'ProjectDetail') return 'ProjectDetail'
|
||||
return String(route.name ?? 'Dashboard')
|
||||
})
|
||||
|
||||
const routePaths: Record<string, string> = {
|
||||
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
|
||||
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
|
||||
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
|
||||
}
|
||||
|
||||
const navigate = (label: string) => {
|
||||
mobileNavOpen.value = false
|
||||
return router.push(routePaths[label] ?? '/dashboard')
|
||||
}
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
const standaloneViews = computed(() => {
|
||||
if (route.name === 'Dashboard') return true
|
||||
if (route.meta?.standalone) return true
|
||||
return false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated) store.refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
|
||||
<div v-else class="shell">
|
||||
<AppSidebar
|
||||
:active-view="activeView"
|
||||
:mobile-nav-open="mobileNavOpen"
|
||||
:queued-tasks="store.snapshot.metrics.queuedTasks"
|
||||
:incidents="store.snapshot.metrics.incidents"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
|
||||
<main>
|
||||
<AppHeader
|
||||
:connected="store.connected"
|
||||
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
|
||||
/>
|
||||
|
||||
<section class="content">
|
||||
<RouterView v-if="standaloneViews" />
|
||||
|
||||
<template v-else>
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<span class="eyebrow">MISSION CONTROL</span>
|
||||
<h1>{{ activeView }}</h1>
|
||||
<p>System overview and operational intelligence across Noveria.</p>
|
||||
</div>
|
||||
<button class="refresh" @click="store.refresh()">
|
||||
<Activity :size="15" :class="{ spin: store.loading }" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModuleView
|
||||
:view="activeView"
|
||||
:snapshot="store.snapshot"
|
||||
:routing="store.routing"
|
||||
@create-project="store.createProject"
|
||||
@create-task="store.createTask"
|
||||
@update-task-state="store.updateTaskState"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-heading h1 { margin: 0; font-size: 18px; }
|
||||
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
|
||||
.eyebrow {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--nx-accent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.refresh {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 11px;
|
||||
border: 1px solid var(--nx-line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--nx-text-dim);
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
|
||||
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.kanban { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
+108
-80
@@ -83,8 +83,11 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
background:
|
||||
radial-gradient(1100px 700px at 12% -10%, rgba(79, 124, 255, 0.10), transparent 60%),
|
||||
radial-gradient(1000px 700px at 95% 8%, rgba(181, 87, 246, 0.09), transparent 60%),
|
||||
var(--space-0, #050410);
|
||||
color: var(--tx, #ece9ff);
|
||||
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif;
|
||||
margin: 0;
|
||||
@@ -92,6 +95,10 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, .font-display {
|
||||
font-family: 'Space Grotesk', 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
/* Nexus overrides for existing CSS variables used in dashboard */
|
||||
:root {
|
||||
--nx-bg: #080a0f;
|
||||
@@ -126,9 +133,9 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px 14px 14px;
|
||||
border-right: 1px solid #1a1e27;
|
||||
background: rgba(9, 11, 16, 0.94);
|
||||
backdrop-filter: blur(18px);
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -143,15 +150,16 @@ body {
|
||||
height: 35px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #443d7c;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, #241f44, #12121f);
|
||||
color: #b8adff;
|
||||
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
@@ -178,36 +186,38 @@ body {
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
padding: 9px 10px;
|
||||
border-radius: 7px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #8991a1;
|
||||
color: var(--tx-2);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav button:hover,
|
||||
.nav button.active {
|
||||
color: #ececf5;
|
||||
background: var(--nx-accent-soft);
|
||||
.nav button:hover {
|
||||
color: var(--tx);
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav button.active {
|
||||
box-shadow: inset 2px 0 var(--nx-accent);
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, rgba(124, 108, 255, 0.22), rgba(124, 108, 255, 0.04));
|
||||
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, 0.25);
|
||||
}
|
||||
|
||||
.nav button i {
|
||||
margin-left: auto;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #343947;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
background: rgba(124, 108, 255, 0.10);
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid #1b1f28;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@@ -240,10 +250,12 @@ body {
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #28243f;
|
||||
color: #bcb3ff;
|
||||
border-radius: 10px;
|
||||
background: var(--grad-soft);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--tx);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -256,9 +268,9 @@ main {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
border-bottom: 1px solid #191d25;
|
||||
background: rgba(8, 10, 15, 0.68);
|
||||
backdrop-filter: blur(16px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(8, 6, 20, 0.5);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.search {
|
||||
@@ -266,19 +278,20 @@ main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #202530;
|
||||
border-radius: 7px;
|
||||
color: #6f7889;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
background: rgba(124, 108, 255, 0.06);
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search kbd {
|
||||
margin-left: auto;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #2c313d;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 4px;
|
||||
color: #606979;
|
||||
color: var(--tx-3);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -293,15 +306,20 @@ main {
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
color: #8c95a5;
|
||||
font-weight: 600;
|
||||
color: var(--tx-2);
|
||||
border: 1px solid var(--line-2);
|
||||
background: rgba(124, 108, 255, 0.07);
|
||||
border-radius: 20px;
|
||||
padding: 4px 11px;
|
||||
}
|
||||
|
||||
.connection.live {
|
||||
color: var(--nx-green);
|
||||
color: var(--st-work);
|
||||
}
|
||||
|
||||
.connection.preview {
|
||||
color: #e6b75d;
|
||||
color: var(--st-queue);
|
||||
}
|
||||
|
||||
.ask,
|
||||
@@ -309,13 +327,20 @@ main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #37315e;
|
||||
border-radius: 7px;
|
||||
background: #18152a;
|
||||
color: #c4bbff;
|
||||
padding: 8px 13px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-purple);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: filter .16s;
|
||||
}
|
||||
|
||||
.ask:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -331,7 +356,7 @@ main {
|
||||
|
||||
.eyebrow,
|
||||
.kicker {
|
||||
color: #7065c8;
|
||||
color: var(--a-mid);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
@@ -351,9 +376,10 @@ h1 {
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border-color: var(--nx-line);
|
||||
background: var(--nx-panel);
|
||||
color: #a5adba;
|
||||
background: rgba(124, 108, 255, 0.07);
|
||||
border: 1px solid var(--line-2);
|
||||
box-shadow: none;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
|
||||
.spin {
|
||||
@@ -370,7 +396,7 @@ h1 {
|
||||
display: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #aaa4e7;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
|
||||
/* ── Keep existing module/layout styles for non-dashboard pages ── */
|
||||
@@ -383,9 +409,10 @@ h1 {
|
||||
|
||||
.metrics article,
|
||||
.panel {
|
||||
border: 1px solid var(--nx-line);
|
||||
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--glass);
|
||||
border-radius: var(--r);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.metrics article {
|
||||
@@ -393,7 +420,7 @@ h1 {
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
color: #717a8a;
|
||||
color: var(--tx-3);
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
@@ -407,12 +434,12 @@ h1 {
|
||||
}
|
||||
|
||||
.metrics small {
|
||||
color: #687181;
|
||||
color: var(--tx-3);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.metrics small.up {
|
||||
color: #55c995;
|
||||
color: var(--st-work);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
@@ -435,7 +462,7 @@ h1 {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #1d222c;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
@@ -446,7 +473,7 @@ h1 {
|
||||
.panel-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #8e96a5;
|
||||
color: var(--tx-2);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -457,18 +484,18 @@ h1 {
|
||||
}
|
||||
|
||||
.badge.positive {
|
||||
color: var(--nx-green);
|
||||
background: rgba(81, 212, 154, 0.1);
|
||||
color: var(--st-work);
|
||||
background: rgba(61, 220, 151, 0.12);
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
color: #e7b660;
|
||||
background: rgba(231, 182, 96, 0.1);
|
||||
color: var(--st-queue);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
}
|
||||
|
||||
.badge.negative {
|
||||
color: #e16e75;
|
||||
background: rgba(225, 110, 117, 0.1);
|
||||
color: var(--st-block);
|
||||
background: rgba(251, 113, 133, 0.12);
|
||||
}
|
||||
|
||||
.runtime-row {
|
||||
@@ -483,9 +510,10 @@ h1 {
|
||||
height: 45px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
color: #ad9fff;
|
||||
background: var(--nx-accent-soft);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--a-mid);
|
||||
background: var(--grad-soft);
|
||||
border: 1px solid var(--line-2);
|
||||
}
|
||||
|
||||
.runtime-main strong,
|
||||
@@ -517,7 +545,7 @@ h1 {
|
||||
width: 3px;
|
||||
min-height: 5px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(#927fff, #443b7c);
|
||||
background: linear-gradient(var(--a-mid), rgba(124, 108, 255, 0.35));
|
||||
}
|
||||
|
||||
.model {
|
||||
@@ -526,11 +554,11 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid #1b2029;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.model > span:last-child {
|
||||
color: #687181;
|
||||
color: var(--tx-3);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
@@ -538,16 +566,16 @@ h1 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #657083;
|
||||
background: var(--st-idle);
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--nx-green);
|
||||
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
|
||||
background: var(--st-work);
|
||||
box-shadow: 0 0 7px rgba(61, 220, 151, 0.4);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: #e16e75;
|
||||
background: var(--st-block);
|
||||
}
|
||||
|
||||
.project {
|
||||
@@ -556,7 +584,7 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #1b2029;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.project-letter {
|
||||
@@ -564,9 +592,9 @@ h1 {
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #353047;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 7px;
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -581,7 +609,7 @@ h1 {
|
||||
}
|
||||
|
||||
.project b {
|
||||
color: #838c9c;
|
||||
color: var(--tx-2);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -590,14 +618,14 @@ h1 {
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #242936;
|
||||
background: var(--space-3);
|
||||
}
|
||||
|
||||
.progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #685ac8, #a091ff);
|
||||
background: var(--grad);
|
||||
}
|
||||
|
||||
.event {
|
||||
@@ -605,7 +633,7 @@ h1 {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #1b2029;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.event > span {
|
||||
@@ -613,19 +641,19 @@ h1 {
|
||||
height: 6px;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
background: #657083;
|
||||
background: var(--st-idle);
|
||||
}
|
||||
|
||||
.event > span.runtime {
|
||||
background: var(--nx-green);
|
||||
background: var(--st-work);
|
||||
}
|
||||
|
||||
.event > span.deploy {
|
||||
background: #8b7cf6;
|
||||
background: var(--a-mid);
|
||||
}
|
||||
|
||||
.event > span.security {
|
||||
background: #e5ad52;
|
||||
background: var(--st-queue);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
@@ -639,7 +667,7 @@ h1 {
|
||||
|
||||
.placeholder svg {
|
||||
margin-bottom: 18px;
|
||||
color: #8074d8;
|
||||
color: var(--a-mid);
|
||||
}
|
||||
|
||||
.placeholder h2 {
|
||||
|
||||
@@ -37,6 +37,14 @@
|
||||
--st-block: #fb7185;
|
||||
--st-idle: #6b6796;
|
||||
|
||||
/* ── Agent/Role Semantic Colors ───────────────────── */
|
||||
--clr-iris: #c084fc;
|
||||
--clr-bao: #60a5fa;
|
||||
--clr-agent: #6ee7b7;
|
||||
--clr-review: #fdba74;
|
||||
--clr-stale: #fda4af;
|
||||
--clr-other: #fb923c;
|
||||
|
||||
/* ── Glows ────────────────────────────────────────── */
|
||||
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
|
||||
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
|
||||
@@ -53,6 +61,45 @@
|
||||
--sidebar-w: 248px;
|
||||
--topbar-h: 62px;
|
||||
--rail-w: 360px;
|
||||
|
||||
/* ── Legacy-Aliasse (v1-Views) → V2-Tokens ────────────
|
||||
Ältere Views konsumieren noch die v1-Variablennamen.
|
||||
Nicht anfassen: --border/--accent (shadcn-HSL-Tripel,
|
||||
werden als hsl(var(--…)) konsumiert). */
|
||||
--nx-bg: var(--space-1);
|
||||
--nx-panel: var(--glass);
|
||||
--nx-panel-soft: var(--glass-2);
|
||||
--nx-line: var(--line);
|
||||
--nx-muted: var(--tx-3);
|
||||
--nx-accent: var(--a-mid);
|
||||
--nx-accent-soft: rgba(124, 108, 255, 0.10);
|
||||
--nx-green: var(--st-work);
|
||||
--nx-text: var(--tx);
|
||||
--nx-text-dim: var(--tx-2);
|
||||
--panel: var(--glass);
|
||||
--card-color: var(--glass);
|
||||
--surface: var(--space-2);
|
||||
--surface-raised: var(--space-3);
|
||||
--accent-soft: rgba(124, 108, 255, 0.10);
|
||||
--accent-secondary: var(--a-purple);
|
||||
--text-primary: var(--tx);
|
||||
--text-secondary: var(--tx-2);
|
||||
--text-muted: var(--tx-3);
|
||||
--text-dim: var(--tx-3);
|
||||
|
||||
/* ── State Pill Colors ───────────────────────────── */
|
||||
--pill-backlog: #fde68a;
|
||||
--pill-progress: #86efac;
|
||||
--pill-done: #86efac;
|
||||
--pill-review: #fdba74;
|
||||
--pill-blocked: #fda4af;
|
||||
|
||||
/* Agent semantic colors (legacy aliases) */
|
||||
--nx-iris: var(--clr-iris);
|
||||
--nx-bao: var(--clr-bao);
|
||||
--nx-agent: var(--clr-agent);
|
||||
--nx-review: var(--clr-review);
|
||||
--nx-stale: var(--clr-stale);
|
||||
}
|
||||
|
||||
/* ── Glass card utility ────────────────────────────── */
|
||||
@@ -108,3 +155,53 @@
|
||||
/* ── Typography helpers ────────────────────────────── */
|
||||
.font-display { font-family: 'Space Grotesk', sans-serif; }
|
||||
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Button Variants (nexus-token-basiert) ──────────── */
|
||||
.nexus-btn-gradient {
|
||||
border: none;
|
||||
background: var(--grad);
|
||||
color: var(--tx);
|
||||
box-shadow: var(--glow-purple);
|
||||
border-radius: var(--r-sm);
|
||||
font-weight: 600;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: opacity .15s, transform .15s;
|
||||
}
|
||||
.nexus-btn-gradient:hover { opacity: .85; transform: translateY(-1px); }
|
||||
.nexus-btn-gradient:active { transform: translateY(0); }
|
||||
|
||||
.nexus-btn-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.nexus-btn-icon:hover { background: rgba(124,108,255,.10); color: var(--tx); }
|
||||
|
||||
.nexus-btn-ghost-subtle {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
border-radius: var(--r-sm);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.nexus-btn-ghost-subtle:hover { background: rgba(124,108,255,.08); color: var(--tx); }
|
||||
|
||||
.nexus-btn-danger {
|
||||
border: 1px solid rgba(251,113,133,.3);
|
||||
background: rgba(251,113,133,.12);
|
||||
color: var(--st-block);
|
||||
border-radius: var(--r-sm);
|
||||
font-weight: 600;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.nexus-btn-danger:hover { background: rgba(251,113,133,.22); }
|
||||
|
||||
@@ -1,630 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
|
||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
||||
import { TASK_STATES } from '../types'
|
||||
import { apiFetch } from '../services/api'
|
||||
import { useOperationsStore } from '../stores/operations'
|
||||
|
||||
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
|
||||
const emit = defineEmits<{
|
||||
createProject: [name: string]
|
||||
createTask: [title: string, priority: string]
|
||||
updateTaskState: [id: string, state: string]
|
||||
}>()
|
||||
const store = useOperationsStore()
|
||||
const agents = ref<AgentInfo[]>([])
|
||||
const agentsLoading = ref(false)
|
||||
|
||||
async function loadAgents() {
|
||||
if (agentsLoading.value) return
|
||||
agentsLoading.value = true
|
||||
agents.value = await store.fetchAgents()
|
||||
agentsLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.view === 'Agents') loadAgents()
|
||||
})
|
||||
|
||||
watch(() => props.view, (v) => {
|
||||
if (v === 'Agents') loadAgents()
|
||||
})
|
||||
|
||||
const newProject = ref('')
|
||||
const newTask = ref('')
|
||||
const message = ref('')
|
||||
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
|
||||
const chatPending = ref(false)
|
||||
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
|
||||
localStorage.setItem('nexus-conversation-id', conversationId.value)
|
||||
|
||||
// Task editing state
|
||||
const editingTaskId = ref<string | null>(null)
|
||||
|
||||
// Task approval / rejection state
|
||||
const approvingTaskId = ref<string | null>(null)
|
||||
const taskActionError = ref('')
|
||||
|
||||
async function handleApproveTask(id: string) {
|
||||
approvingTaskId.value = id
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.approveTask(id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
|
||||
} finally {
|
||||
approvingTaskId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectTask(id: string) {
|
||||
approvingTaskId.value = id
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.rejectTask(id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
|
||||
} finally {
|
||||
approvingTaskId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Task deletion state
|
||||
const deletingTaskId = ref<string | null>(null)
|
||||
const deleteError = ref('')
|
||||
|
||||
async function confirmDeleteTask(id: string) {
|
||||
deleteError.value = ''
|
||||
try {
|
||||
await store.deleteTask(id)
|
||||
deletingTaskId.value = null
|
||||
} catch (e) {
|
||||
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDeleteTask() {
|
||||
deletingTaskId.value = null
|
||||
deleteError.value = ''
|
||||
}
|
||||
const editTaskTitle = ref('')
|
||||
const editTaskPriority = ref('')
|
||||
const editTaskProjectId = ref<string | null>(null)
|
||||
|
||||
// Activity filtering and pagination
|
||||
const activityTypeFilter = ref('')
|
||||
const activitySort = ref('newest')
|
||||
const activityPage = ref(1)
|
||||
const activityPageSize = 20
|
||||
const activityTotalPages = ref(1)
|
||||
const activityTotalCount = ref(0)
|
||||
|
||||
const columns = computed(() =>
|
||||
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
const types = new Set(props.snapshot.activity.map(e => e.type))
|
||||
return Array.from(types)
|
||||
})
|
||||
|
||||
const filteredActivity = computed(() => {
|
||||
let items = [...props.snapshot.activity]
|
||||
if (activityTypeFilter.value) {
|
||||
items = items.filter(e => e.type === activityTypeFilter.value)
|
||||
}
|
||||
if (activitySort.value === 'oldest') {
|
||||
items.reverse()
|
||||
}
|
||||
const total = items.length
|
||||
activityTotalCount.value = total
|
||||
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
|
||||
const start = (activityPage.value - 1) * activityPageSize
|
||||
return items.slice(start, start + activityPageSize)
|
||||
})
|
||||
|
||||
watch(activityTypeFilter, () => { activityPage.value = 1 })
|
||||
watch(activitySort, () => { activityPage.value = 1 })
|
||||
|
||||
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
|
||||
editingTaskId.value = task.id
|
||||
editTaskTitle.value = task.title
|
||||
editTaskPriority.value = task.priority
|
||||
editTaskProjectId.value = task.projectId ?? null
|
||||
}
|
||||
|
||||
async function saveEditTask(id: string) {
|
||||
try {
|
||||
await store.updateTask(id, {
|
||||
title: editTaskTitle.value.trim() || undefined,
|
||||
priority: editTaskPriority.value || undefined,
|
||||
projectId: editTaskProjectId.value || undefined,
|
||||
})
|
||||
editingTaskId.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to update task', e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditTask() {
|
||||
editingTaskId.value = null
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const value = message.value.trim()
|
||||
if (!value || chatPending.value) return
|
||||
chatMessages.value.push({ role: 'owner', content: value })
|
||||
message.value = ''
|
||||
chatPending.value = true
|
||||
try {
|
||||
const response = await apiFetch('/api/v1/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
|
||||
conversationId.value = payload.conversationId
|
||||
localStorage.setItem('nexus-conversation-id', payload.conversationId)
|
||||
chatMessages.value.push({ role: 'iris', content: payload.content })
|
||||
} catch (error) {
|
||||
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
|
||||
} finally {
|
||||
chatPending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
|
||||
<div v-if="view === 'Projects'" class="module-grid">
|
||||
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
|
||||
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
|
||||
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
|
||||
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
|
||||
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
|
||||
<div v-if="view === 'Task Board'" class="kanban">
|
||||
<section v-for="column in columns" :key="column.name" class="kanban-column">
|
||||
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
|
||||
<article v-for="task in column.items" :key="task.id" class="task-card">
|
||||
<template v-if="editingTaskId === task.id">
|
||||
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
|
||||
<div class="task-edit-row">
|
||||
<select v-model="editTaskPriority">
|
||||
<option value="Critical">Critical</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Normal">Normal</option>
|
||||
<option value="Low">Low</option>
|
||||
</select>
|
||||
<select v-model="editTaskProjectId">
|
||||
<option :value="null">No project</option>
|
||||
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="task-edit-actions">
|
||||
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
|
||||
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="task-card-head">
|
||||
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
|
||||
<div class="task-card-actions">
|
||||
<template v-if="task.state === 'In progress'">
|
||||
<button
|
||||
class="task-approve-btn"
|
||||
title="Approve"
|
||||
:disabled="approvingTaskId === task.id"
|
||||
@click="handleApproveTask(task.id)"
|
||||
><CheckCircle2 :size="13" /></button>
|
||||
<button
|
||||
class="task-reject-btn"
|
||||
title="Reject"
|
||||
:disabled="approvingTaskId === task.id"
|
||||
@click="handleRejectTask(task.id)"
|
||||
><X :size="13" /></button>
|
||||
</template>
|
||||
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
|
||||
<button
|
||||
v-if="task.state === 'Done' || task.state === 'Backlog'"
|
||||
class="task-delete-btn"
|
||||
title="Delete task"
|
||||
@click="deletingTaskId = task.id; deleteError = ''"
|
||||
><Trash2 :size="12" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{{ task.title }}</h3>
|
||||
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
|
||||
</select>
|
||||
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
|
||||
</template>
|
||||
</article>
|
||||
<div v-if="!column.items.length" class="empty-state">No tasks</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Agents'" class="module-grid">
|
||||
<div v-if="agentsLoading" class="loading-agents">Loading agents…</div>
|
||||
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
|
||||
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
|
||||
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
|
||||
<Zap v-else :size="22" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
|
||||
<h3>{{ agent.name }}</h3>
|
||||
<p>{{ agent.description || agent.model }}</p>
|
||||
</div>
|
||||
<div class="agent-status-group">
|
||||
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
|
||||
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Models'" class="module-list panel">
|
||||
<div v-for="model in routing" :key="model.model" class="model-detail">
|
||||
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Activity'" class="activity-panel panel">
|
||||
<div class="activity-filters">
|
||||
<div class="filter-group">
|
||||
<label>Type</label>
|
||||
<select v-model="activityTypeFilter">
|
||||
<option value="">All types</option>
|
||||
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Sort</label>
|
||||
<select v-model="activitySort">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<article v-for="event in filteredActivity" :key="event.message + event.at">
|
||||
<div :class="['timeline-icon', event.type]">
|
||||
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
|
||||
<ShieldAlert v-else :size="15" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="kicker">{{ event.type }}</span>
|
||||
<h3>{{ event.message }}</h3>
|
||||
<p>{{ new Date(event.at).toLocaleString() }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="activityTotalPages > 1" class="activity-pagination">
|
||||
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
|
||||
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
|
||||
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Settings'" class="settings-redirect">
|
||||
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
|
||||
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
|
||||
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
|
||||
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
|
||||
</div>
|
||||
|
||||
<!-- Task deletion confirmation dialog -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
|
||||
<div class="delete-dialog">
|
||||
<h3>Delete Task?</h3>
|
||||
<p>This action cannot be undone. The task will be permanently removed.</p>
|
||||
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
|
||||
<div class="delete-actions">
|
||||
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
|
||||
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.task-card-actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
align-items: center;
|
||||
}
|
||||
.task-edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.task-card:hover .task-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-edit-btn:hover {
|
||||
color: var(--nx-accent);
|
||||
}
|
||||
.task-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.task-card:hover .task-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-delete-btn:hover {
|
||||
color: var(--danger, #e74c3c);
|
||||
}
|
||||
.task-edit-input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.task-edit-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.task-edit-row select {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.task-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.task-edit-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--nx-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-edit-cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.activity-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.filter-group label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.filter-group select {
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.activity-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.activity-pagination button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.activity-pagination span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.project-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.settings-redirect {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.settings-redirect a {
|
||||
color: var(--nx-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.settings-redirect a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Task deletion confirmation */
|
||||
.delete-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.delete-dialog {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 380px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
.delete-dialog h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.delete-dialog p {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.delete-error {
|
||||
color: var(--danger, #e74c3c) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.delete-cancel {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.delete-confirm {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--danger, #e74c3c);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Agent card enhancements */
|
||||
.agent-status-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.agent-model-tag {
|
||||
font-size: 0.7rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.loading-agents {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Task approve/reject buttons */
|
||||
.task-approve-btn,
|
||||
.task-reject-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.task-card:hover .task-approve-btn,
|
||||
.task-card:hover .task-reject-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-approve-btn {
|
||||
color: var(--success, #27ae60);
|
||||
}
|
||||
.task-approve-btn:hover {
|
||||
color: var(--success, #27ae60);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
.task-reject-btn {
|
||||
color: var(--warning, #f39c12);
|
||||
}
|
||||
.task-reject-btn:hover {
|
||||
color: var(--danger, #e74c3c);
|
||||
}
|
||||
.task-approve-btn:disabled,
|
||||
.task-reject-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BoardCard — eine Master-Task-Karte im Board.
|
||||
*
|
||||
* Zeigt nur Top-Level-Tasks; Child-Tasks der Agenten leben ausklappbar
|
||||
* IN der Karte (gruppiert nach Agent) statt als eigene Spalten-Karten —
|
||||
* so bleibt das Board übersichtlich, auch wenn Iris groß zerlegt.
|
||||
*
|
||||
* Ball = wer gerade dran ist. Stalled = In-Bearbeitung ohne Aktivität
|
||||
* seit der Schwelle (Watchdog meldet parallel an Iris).
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { ChevronRight, Check, RotateCcw, Bot, User, AlertTriangle } from '@lucide/vue'
|
||||
import type { DashboardTaskDto } from '../../stores/tasks'
|
||||
import { TASK_AGENT_LABELS } from '../../constants/agentPool'
|
||||
|
||||
const props = defineProps<{
|
||||
task: DashboardTaskDto
|
||||
column: string
|
||||
canReview: boolean
|
||||
stallThresholdMin: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [id: string]
|
||||
approve: [id: string]
|
||||
requestChanges: [task: DashboardTaskDto]
|
||||
dragstart: [e: DragEvent, id: string]
|
||||
dragend: [e: DragEvent]
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
const children = computed(() => props.task.childTasks ?? [])
|
||||
const hasChildren = computed(() => children.value.length > 0)
|
||||
|
||||
const totalChildren = computed(() => props.task.childTaskCount ?? children.value.length)
|
||||
const doneChildren = computed(() =>
|
||||
props.task.doneChildTaskCount ?? children.value.filter(c => c.state === 'Done').length
|
||||
)
|
||||
const progressPct = computed(() =>
|
||||
totalChildren.value > 0 ? Math.round((doneChildren.value / totalChildren.value) * 100) : 0
|
||||
)
|
||||
|
||||
/* ── Ball: wer ist dran ───────────────────────────── */
|
||||
const ballAgent = computed(() => {
|
||||
const s = props.task.state.toLowerCase()
|
||||
if (s === 'review') return 'bao'
|
||||
if (s === 'done') return null
|
||||
if (s === 'backlog') return props.task.expectedFrom || 'iris'
|
||||
return props.task.expectedFrom || props.task.assignedTo || 'iris'
|
||||
})
|
||||
|
||||
function agentLabel(id?: string | null): string {
|
||||
if (!id) return '—'
|
||||
return TASK_AGENT_LABELS[id.toLowerCase()] ?? id
|
||||
}
|
||||
|
||||
function agentClass(id?: string | null): string {
|
||||
const lower = (id ?? '').toLowerCase()
|
||||
if (lower === 'iris') return 'is-iris'
|
||||
if (lower === 'bao') return 'is-bao'
|
||||
return 'is-agent'
|
||||
}
|
||||
|
||||
/* ── Stalled-Erkennung (rein aus Aktivitätszeit) ──── */
|
||||
function minutesSince(dateStr?: string | null): number {
|
||||
if (!dateStr) return Infinity
|
||||
return (Date.now() - new Date(dateStr).getTime()) / 60000
|
||||
}
|
||||
|
||||
function isStalled(t: DashboardTaskDto): boolean {
|
||||
if (t.state.toLowerCase() !== 'in progress') return false
|
||||
return minutesSince(t.lastActivityAt ?? t.updatedAt) > props.stallThresholdMin
|
||||
}
|
||||
|
||||
const masterStalled = computed(() => {
|
||||
if (hasChildren.value) return children.value.some(isStalled)
|
||||
return isStalled(props.task)
|
||||
})
|
||||
|
||||
/* ── Child-Gruppierung nach Agent ─────────────────── */
|
||||
const childrenByAgent = computed(() => {
|
||||
const groups = new Map<string, DashboardTaskDto[]>()
|
||||
for (const child of children.value) {
|
||||
const key = child.assignedTo || 'unassigned'
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(child)
|
||||
}
|
||||
return [...groups.entries()].map(([agent, tasks]) => ({ agent, tasks }))
|
||||
})
|
||||
|
||||
const assigneeInitials = computed(() => {
|
||||
const unique = new Set(children.value.map(c => c.assignedTo).filter(Boolean) as string[])
|
||||
if (!unique.size && props.task.assignedTo) unique.add(props.task.assignedTo)
|
||||
return [...unique].slice(0, 4).map(a => agentLabel(a).replace(/^[^\w]+/, '').slice(0, 2).toUpperCase())
|
||||
})
|
||||
|
||||
function priorityLabel(p: string): string {
|
||||
const lower = p.toLowerCase()
|
||||
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'High'
|
||||
if (lower === 'low' || lower === 'minor') return 'Low'
|
||||
return 'Med'
|
||||
}
|
||||
|
||||
function priorityClass(p: string): string {
|
||||
const lower = p.toLowerCase()
|
||||
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'prio-high'
|
||||
if (lower === 'low' || lower === 'minor') return 'prio-low'
|
||||
return 'prio-med'
|
||||
}
|
||||
|
||||
function childStateLabel(state: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'backlog': 'Offen', 'in progress': 'Aktiv', 'review': 'Review', 'blocked': 'Blockiert', 'done': 'Fertig',
|
||||
}
|
||||
return map[state.toLowerCase()] ?? state
|
||||
}
|
||||
|
||||
function childStateClass(state: string): string {
|
||||
const s = state.toLowerCase()
|
||||
if (s === 'done') return 'cs-done'
|
||||
if (s === 'blocked') return 'cs-blocked'
|
||||
if (s === 'review') return 'cs-review'
|
||||
if (s === 'in progress') return 'cs-active'
|
||||
return 'cs-backlog'
|
||||
}
|
||||
|
||||
function relTime(date?: string | null): string {
|
||||
if (!date) return 'keine Aktivität'
|
||||
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000))
|
||||
if (mins < 1) return 'gerade eben'
|
||||
if (mins < 60) return `vor ${mins} min`
|
||||
const h = Math.round(mins / 60)
|
||||
if (h < 24) return `vor ${h} h`
|
||||
return `vor ${Math.round(h / 24)} d`
|
||||
}
|
||||
|
||||
function toggleExpand(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mcard"
|
||||
:class="{ 'mcard-blocked': column === 'blocked', 'mcard-stalled': masterStalled }"
|
||||
draggable="true"
|
||||
@click="emit('open', task.id)"
|
||||
@dragstart="emit('dragstart', $event, task.id)"
|
||||
@dragend="emit('dragend', $event)"
|
||||
>
|
||||
<!-- Kopf: Ball + Priorität + Stalled -->
|
||||
<div class="mcard-top">
|
||||
<span v-if="ballAgent" class="ball" :class="agentClass(ballAgent)" :title="'Ball bei ' + agentLabel(ballAgent)">
|
||||
<Bot v-if="ballAgent === 'iris'" :size="11" />
|
||||
<User v-else-if="ballAgent === 'bao'" :size="11" />
|
||||
<span v-else class="ball-dot"></span>
|
||||
{{ agentLabel(ballAgent) }}
|
||||
</span>
|
||||
<span class="prio" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||||
<span v-if="masterStalled" class="stalled-chip" title="Keine Aktivität seit der Schwelle — Iris benachrichtigt">
|
||||
<AlertTriangle :size="11" /> hängt
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Titel -->
|
||||
<div class="mcard-title">{{ task.title }}</div>
|
||||
|
||||
<!-- Fortschritt aus Children -->
|
||||
<div v-if="hasChildren" class="mcard-progress">
|
||||
<div class="progress-row">
|
||||
<button class="expand-btn" :class="{ open: expanded }" @click="toggleExpand" :aria-label="expanded ? 'Einklappen' : 'Ausklappen'">
|
||||
<ChevronRight :size="14" />
|
||||
</button>
|
||||
<span class="progress-text">{{ doneChildren }}/{{ totalChildren }} Teilaufgaben</span>
|
||||
<div class="avatars">
|
||||
<span v-for="(ini, i) in assigneeInitials" :key="i" class="avatar-mini">{{ ini }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-track"><div class="progress-fill" :style="{ width: progressPct + '%' }"></div></div>
|
||||
</div>
|
||||
<div v-else-if="task.detail" class="mcard-preview">{{ task.detail }}</div>
|
||||
|
||||
<!-- Ausgeklappte Children, gruppiert nach Agent -->
|
||||
<div v-if="expanded && hasChildren" class="children" @click.stop>
|
||||
<div v-for="group in childrenByAgent" :key="group.agent" class="child-group">
|
||||
<div class="child-group-head">
|
||||
<span class="child-agent" :class="agentClass(group.agent)">{{ agentLabel(group.agent) }}</span>
|
||||
<span class="child-group-count">{{ group.tasks.length }}</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="child in group.tasks"
|
||||
:key="child.id"
|
||||
type="button"
|
||||
class="child-row"
|
||||
@click.stop="emit('open', child.id)"
|
||||
>
|
||||
<span class="child-title">{{ child.title }}</span>
|
||||
<span class="child-tail">
|
||||
<span v-if="isStalled(child)" class="child-stalled" title="hängt"><AlertTriangle :size="10" /></span>
|
||||
<span class="child-state" :class="childStateClass(child.state)">{{ childStateLabel(child.state) }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Review-Aktionen -->
|
||||
<div v-if="column === 'review' && canReview" class="review-actions" @click.stop>
|
||||
<button class="rv-approve" @click="emit('approve', task.id)"><Check :size="13" /> Abnehmen</button>
|
||||
<button class="rv-changes" @click="emit('requestChanges', task)"><RotateCcw :size="13" /> Änderung</button>
|
||||
</div>
|
||||
|
||||
<div class="mcard-meta">
|
||||
<span>Update {{ relTime(task.lastActivityAt ?? task.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mcard {
|
||||
padding: 11px 12px;
|
||||
border-radius: var(--r-sm, 10px);
|
||||
background: linear-gradient(160deg, rgba(28,24,64,.45), rgba(20,17,48,.35));
|
||||
border: 1px solid var(--line);
|
||||
cursor: pointer;
|
||||
transition: transform .15s, box-shadow .2s, border-color .15s;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.mcard:hover { transform: translateY(-1px); border-color: var(--line-2); box-shadow: 0 8px 24px -6px rgba(0,0,0,.4); }
|
||||
.mcard-blocked { border-left: 3px solid var(--st-block); }
|
||||
.mcard-stalled { border-left: 3px solid var(--st-queue); }
|
||||
|
||||
.mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; }
|
||||
.ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; }
|
||||
.ball.is-iris { background: rgba(147, 51, 234, .16); color: var(--clr-iris); }
|
||||
.ball.is-bao { background: rgba(59, 130, 246, .16); color: var(--clr-bao); }
|
||||
.ball.is-agent { background: rgba(16, 185, 129, .14); color: var(--clr-agent); }
|
||||
.ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
|
||||
.prio-high { color: var(--st-block); border-color: var(--st-block); }
|
||||
.prio-med { color: var(--st-queue); border-color: var(--st-queue); }
|
||||
.prio-low { color: var(--a-blue); border-color: var(--a-blue); }
|
||||
.stalled-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; font-size: 9.5px; font-weight: 600; color: var(--st-queue); background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); padding: 1px 6px; border-radius: 20px; }
|
||||
|
||||
.mcard-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
|
||||
.mcard-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
.mcard-progress { margin-top: 9px; }
|
||||
.progress-row { display: flex; align-items: center; gap: 8px; }
|
||||
.expand-btn { display: grid; place-items: center; width: 20px; height: 20px; border: none; border-radius: 6px; background: rgba(124,108,255,.08); color: var(--tx-2); cursor: pointer; transition: transform .15s, background .15s; flex: 0 0 auto; }
|
||||
.expand-btn:hover { background: rgba(124,108,255,.16); color: var(--tx); }
|
||||
.expand-btn.open { transform: rotate(90deg); }
|
||||
.progress-text { font-size: 10.5px; color: var(--tx-2); font-family: 'Manrope', sans-serif; }
|
||||
.avatars { margin-left: auto; display: flex; }
|
||||
.avatar-mini { width: 20px; height: 20px; margin-left: -6px; border-radius: 50%; background: var(--grad-soft); border: 1px solid var(--space-1); display: grid; place-items: center; font-size: 8px; font-weight: 700; color: var(--tx); font-family: 'JetBrains Mono', monospace; }
|
||||
.avatar-mini:first-child { margin-left: 0; }
|
||||
.progress-track { height: 4px; margin-top: 6px; border-radius: 2px; background: var(--space-3); overflow: hidden; }
|
||||
.progress-fill { height: 100%; border-radius: 2px; background: var(--grad); transition: width .3s; }
|
||||
|
||||
.children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; }
|
||||
.child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.child-agent.is-iris { background: rgba(147, 51, 234, .14); color: var(--clr-iris); }
|
||||
.child-agent.is-bao { background: rgba(59, 130, 246, .14); color: var(--clr-bao); }
|
||||
.child-agent.is-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); }
|
||||
.child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); }
|
||||
.child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; }
|
||||
.child-row:hover { background: rgba(124,108,255,.08); }
|
||||
.child-title { flex: 1; font-size: 11px; line-height: 1.35; word-break: break-word; }
|
||||
.child-tail { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
|
||||
.child-stalled { color: var(--st-queue); display: inline-flex; }
|
||||
.child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.cs-done { background: rgba(61,220,151,.14); color: var(--st-work); }
|
||||
.cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); }
|
||||
.cs-review { background: rgba(251, 146, 60, .14); color: var(--clr-review); }
|
||||
.cs-active { background: rgba(52,214,245,.14); color: var(--st-think); }
|
||||
.cs-backlog { background: var(--glass-2); color: var(--tx-3); }
|
||||
|
||||
.review-actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
.rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; }
|
||||
.rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); }
|
||||
.rv-approve:hover { background: rgba(61,220,151,.26); }
|
||||
.rv-changes { border: 1px solid rgba(251, 146, 60, .3); background: rgba(251, 146, 60, .12); color: var(--clr-review); }
|
||||
.rv-changes:hover { background: rgba(251,146,60,.22); }
|
||||
|
||||
.mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
@@ -10,6 +10,9 @@ defineProps<{
|
||||
saving: boolean
|
||||
saveStatus: 'idle' | 'saved' | 'error'
|
||||
saveMessage: string
|
||||
backupStatus: string
|
||||
reloadStatus: string
|
||||
reloadMessage: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -60,6 +63,12 @@ function onInput(event: Event) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="reloadMessage" class="editor-health">
|
||||
<span class="health-pill" :class="backupStatus">Backup {{ backupStatus }}</span>
|
||||
<span class="health-pill" :class="reloadStatus">Reload {{ reloadStatus }}</span>
|
||||
<span class="health-note">{{ reloadMessage }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Text editor -->
|
||||
<textarea
|
||||
class="config-editor"
|
||||
@@ -89,6 +98,17 @@ function onInput(event: Event) {
|
||||
border-bottom: 1px solid var(--line, #1e2030);
|
||||
gap: 12px;
|
||||
}
|
||||
.editor-health {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--line, #1e2030);
|
||||
background: rgba(255,255,255,.015);
|
||||
color: #8e96a8;
|
||||
font-size: 10.5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.editor-file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -165,6 +185,30 @@ function onInput(event: Event) {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.health-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.health-pill.created {
|
||||
color: #51d49a;
|
||||
border-color: rgba(81,212,154,.3);
|
||||
}
|
||||
.health-pill.not_applicable {
|
||||
color: #d4b26a;
|
||||
border-color: rgba(212,178,106,.25);
|
||||
}
|
||||
.health-pill.not_supported {
|
||||
color: #9aa4bb;
|
||||
border-color: rgba(154,164,187,.25);
|
||||
}
|
||||
.health-note {
|
||||
color: #7e8799;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
width: 100%;
|
||||
|
||||
@@ -298,7 +298,7 @@ function avatarLabel() {
|
||||
|
||||
.m-av.iris {
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
color: var(--tx);
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
@@ -328,13 +328,13 @@ function avatarLabel() {
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
|
||||
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
|
||||
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
|
||||
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
|
||||
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
|
||||
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
|
||||
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
|
||||
.badge-blue { background:rgba(79,124,255,.14); color:var(--a-blue); border-color:rgba(79,124,255,.3); }
|
||||
.badge-purple { background:rgba(181,87,246,.14); color:var(--a-purple); border-color:rgba(181,87,246,.3); }
|
||||
.badge-amber { background:rgba(251,191,36,.13); color:var(--st-queue); border-color:rgba(251,191,36,.3); }
|
||||
.badge-green { background:rgba(61,220,151,.13); color:var(--st-work); border-color:rgba(61,220,151,.3); }
|
||||
.badge-cyan { background:rgba(52,214,245,.13); color:var(--st-think); border-color:rgba(52,214,245,.3); }
|
||||
.badge-rose { background:rgba(251,113,133,.13); color:var(--st-block); border-color:rgba(251,113,133,.3); }
|
||||
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
|
||||
|
||||
.m-pill {
|
||||
display: inline-flex;
|
||||
@@ -428,7 +428,7 @@ function avatarLabel() {
|
||||
}
|
||||
|
||||
.m-bar.work i {
|
||||
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||
background: linear-gradient(90deg, var(--st-work), var(--st-work));
|
||||
box-shadow: var(--glow-work);
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ function avatarLabel() {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #9fe8fb;
|
||||
color: var(--st-think);
|
||||
min-height: 72px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -593,7 +593,7 @@ function avatarLabel() {
|
||||
.m-model-btn.active {
|
||||
background: var(--grad);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--tx);
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ defineEmits<{
|
||||
|
||||
.nc-av.iris-av {
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
color: var(--tx);
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ defineEmits<{
|
||||
}
|
||||
|
||||
.node.is-work .nc-bar i {
|
||||
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||
background: linear-gradient(90deg, var(--st-work), var(--st-work));
|
||||
}
|
||||
|
||||
/* ── Meta ────────────────────────────────────── */
|
||||
|
||||
@@ -159,7 +159,7 @@ defineEmits<{
|
||||
border: 1px solid rgba(251,113,133,.3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: #fda4b0;
|
||||
color: var(--st-block);
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
|
||||
@@ -86,7 +86,7 @@ function renderEdges() {
|
||||
|
||||
const edgeList = buildEdges(props.agents)
|
||||
|
||||
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
|
||||
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="var(--a-blue)"/><stop offset="1" stop-color="var(--a-purple)"/></linearGradient></defs>`
|
||||
let paths = ''
|
||||
let pulses = ''
|
||||
let idCounter = 0
|
||||
@@ -105,17 +105,17 @@ function renderEdges() {
|
||||
if (e.kind === 'flow' && live) {
|
||||
// Active flow: gradient stroke + animate pulse
|
||||
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
|
||||
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
|
||||
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
paths += `<path d="${d}" fill="none" stroke="var(--st-work)" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
|
||||
pulses += `<circle r="3.4" fill="var(--st-work)"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
} else if (e.kind === 'flow') {
|
||||
// Inactive flow
|
||||
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
|
||||
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
pulses += `<circle r="2.8" fill="var(--a-mid)" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
} else {
|
||||
// Orchestration (Iris → Agent)
|
||||
const targetAgent = props.agents.find(a => a.id === e.b)
|
||||
const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
|
||||
paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
|
||||
paths += `<path d="${d}" fill="none" stroke="var(--a-mid)" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -336,7 +336,7 @@ function handleReset() {
|
||||
border-radius: 10px;
|
||||
background: var(--grad);
|
||||
border: none;
|
||||
color: #fff;
|
||||
color: var(--tx);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -123,7 +123,7 @@ watch(
|
||||
.iris-av :deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: #fff;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.iris-name {
|
||||
@@ -196,7 +196,7 @@ watch(
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.chat-msg-info.error { color: #fda4b0; font-style: normal; }
|
||||
.chat-msg-info.error { color: var(--st-block); font-style: normal; }
|
||||
|
||||
.chat-row {
|
||||
display: flex;
|
||||
@@ -222,7 +222,7 @@ watch(
|
||||
|
||||
.bubble.me {
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
color: var(--tx);
|
||||
border-bottom-right-radius: 5px;
|
||||
margin-left: auto;
|
||||
box-shadow: var(--glow-purple);
|
||||
@@ -313,6 +313,6 @@ watch(
|
||||
.send :deep(svg) {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: #fff;
|
||||
color: var(--tx);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@ function prioLabel(p: TaskItem['priority']): string {
|
||||
}
|
||||
|
||||
function prioColor(p: TaskItem['priority']): string {
|
||||
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
|
||||
return p === 'high' ? 'var(--st-block)' : p === 'medium' ? 'var(--st-queue)' : 'var(--a-blue)'
|
||||
}
|
||||
|
||||
function dotClass(s: TaskItem['status']): string {
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggleMobileNav: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
|
||||
<Command :size="19" />
|
||||
</button>
|
||||
<div class="search">
|
||||
<Search :size="16" />
|
||||
<span>Search operations</span>
|
||||
<kbd>⌘ K</kbd>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span :class="['connection', connected ? 'live' : 'preview']">
|
||||
<CircleDot :size="13" />
|
||||
{{ connected ? 'Live' : 'Preview data' }}
|
||||
</span>
|
||||
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--nx-line, #1f2330);
|
||||
background: var(--nx-panel, #11141b);
|
||||
}
|
||||
.mobile-menu { display: none; }
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--nx-line, #1f2330);
|
||||
border-radius: 7px;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
font-size: 11px;
|
||||
}
|
||||
.search kbd {
|
||||
margin-left: auto;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid #2a2f3d;
|
||||
border-radius: 4px;
|
||||
font-size: 8px;
|
||||
color: #4a5266;
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.connection.live { color: #27ae60; background: rgba(39,174,96,.1); }
|
||||
.connection.preview { color: #e67e22; background: rgba(230,126,34,.1); }
|
||||
.ask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--nx-accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--nx-line, #1f2330); border-radius: 6px; background: transparent; color: var(--nx-accent, #7b6ef2); cursor: pointer; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,216 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import {
|
||||
Activity, Bell, Bot, Boxes, Command, FileText,
|
||||
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
|
||||
Shield, SlidersHorizontal, Sparkles, BookOpen,
|
||||
AlertTriangle, Calendar,
|
||||
} from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useNotificationStore } from '../../stores/notifications'
|
||||
import { initials } from '../../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
activeView: string
|
||||
mobileNavOpen: boolean
|
||||
queuedTasks: number
|
||||
incidents: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [label: string]
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
onMounted(() => {
|
||||
notificationStore.startPolling()
|
||||
})
|
||||
|
||||
const ownerInitials = computed(() =>
|
||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||
)
|
||||
|
||||
const navigation = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Memory', icon: FileText },
|
||||
{ label: 'Docs', icon: BookOpen },
|
||||
{ label: 'Security', icon: Shield },
|
||||
{ label: 'Projects', icon: Boxes },
|
||||
{ label: 'Task Board', icon: ListTodo },
|
||||
{ label: 'Incidents', icon: AlertTriangle },
|
||||
{ separator: true },
|
||||
{ label: 'Notifications', icon: Bell },
|
||||
{ label: 'Calendar', icon: Calendar },
|
||||
{ label: 'Agents', icon: Bot },
|
||||
{ label: 'Models', icon: SlidersHorizontal },
|
||||
{ label: 'Activity', icon: Activity },
|
||||
{ label: 'Mobile Chat', icon: MessageSquareText },
|
||||
]
|
||||
|
||||
function onNavigate(label: string) {
|
||||
emit('navigate', label)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="['sidebar', { open: mobileNavOpen }]">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><Command :size="18" /></div>
|
||||
<div>
|
||||
<strong>NEXUS</strong>
|
||||
<span>Noveria Operations</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<template v-for="item in navigation" :key="item.label ?? 'sep'">
|
||||
<div v-if="item.separator" class="nav-separator"></div>
|
||||
<button
|
||||
v-else
|
||||
:class="{ active: activeView === item.label }"
|
||||
@click="onNavigate(item.label)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" />
|
||||
<span>{{ item.label }}</span>
|
||||
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
|
||||
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
|
||||
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
|
||||
<button class="owner" type="button" title="Sign out" @click="logout">
|
||||
<div class="avatar">{{ ownerInitials }}</div>
|
||||
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
|
||||
<LogOut :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 210px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel, #11141b);
|
||||
border-right: 1px solid var(--line, #1f2330);
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 10px 12px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
}
|
||||
.brand div strong { display: block; font-size: 10px; letter-spacing: .08em; }
|
||||
.brand div span { font-size: 8px; color: var(--text-dim, #6f7889); }
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9ea5b3;
|
||||
font-size: 10.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.nav button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.nav button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
|
||||
.nav button i {
|
||||
margin-left: auto;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.nav button i.badge-red {
|
||||
background: #e16e75;
|
||||
}
|
||||
.nav-separator {
|
||||
height: 1px;
|
||||
margin: 6px 10px;
|
||||
background: var(--nx-line, #1f2330);
|
||||
}
|
||||
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
|
||||
.sidebar-bottom > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9ea5b3;
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar-bottom > button:hover { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.sidebar-bottom > button.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
|
||||
.owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.owner div strong { display: block; font-size: 9px; }
|
||||
.owner div span { font-size: 7.5px; color: var(--text-dim, #6f7889); text-transform: capitalize; }
|
||||
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
|
||||
.owner:hover > svg:last-child { opacity: 1; }
|
||||
.avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavItemDef } from '../../composables/icons'
|
||||
import NavItem from './NavItem.vue'
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
items: NavItemDef[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nav-group">
|
||||
<div class="nav-group-label">{{ label }}</div>
|
||||
<NavItem
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
:icon="item.icon"
|
||||
:label="item.label"
|
||||
:route="item.route"
|
||||
:count="item.count"
|
||||
:active="item.active"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-group-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: .18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--tx-3);
|
||||
font-weight: 700;
|
||||
padding: 16px 10px 7px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { icons } from '../../composables/icons'
|
||||
|
||||
const props = defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
route?: string
|
||||
count?: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const isActive = computed(() => {
|
||||
if (props.active) return true
|
||||
if (props.route && route.path === props.route) return true
|
||||
return false
|
||||
})
|
||||
|
||||
function navigate() {
|
||||
if (props.route) {
|
||||
router.push(props.route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="['nav-item', { active: isActive }]"
|
||||
@click="navigate"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<span class="nav-icon" v-html="icons[icon] || ''"></span>
|
||||
|
||||
<!-- Label -->
|
||||
<span class="nav-label">{{ label }}</span>
|
||||
|
||||
<!-- Count badge -->
|
||||
<span v-if="count !== undefined" class="count">{{ count }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background .16s, color .16s;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(124,108,255,.08);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
|
||||
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
border-radius: 3px;
|
||||
background: var(--grad);
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.nav-icon :deep(svg) {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 20px;
|
||||
background: rgba(124,108,255,.16);
|
||||
color: var(--tx);
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,139 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sidebar — kompakte Icon-Rail (V2-Shell, alle Seiten)
|
||||
*
|
||||
* Collapsed 68px, expandiert bei Hover auf 232px als Overlay
|
||||
* (kein Layout-Shift im Content). Ersetzt Sidebar + Topbar.
|
||||
* Mobile: als Drawer über mobileOpen/close.
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useAgentStore } from '../../stores/agents'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { navigation, icons } from '../../composables/icons'
|
||||
import type { NavGroupDef } from '../../composables/icons'
|
||||
import { useNotificationStore } from '../../stores/notifications'
|
||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
||||
import { railNav, railFooterNav, svg } from '../../composables/icons'
|
||||
import { initials } from '../../utils/format'
|
||||
|
||||
defineProps<{
|
||||
mobileOpen?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
import NavGroup from './NavGroup.vue'
|
||||
import { initials } from '../../utils/format'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const liveSync = useLiveSyncStore()
|
||||
|
||||
const ownerInitials = computed(() =>
|
||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||
)
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.replace('/login')
|
||||
function isActive(itemRoute?: string): boolean {
|
||||
if (!itemRoute) return false
|
||||
if (route.path === itemRoute) return true
|
||||
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
|
||||
return route.path.startsWith(itemRoute + '/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamische Nav-Item-Counts aus den Stores.
|
||||
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
|
||||
*/
|
||||
const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
||||
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
|
||||
return navigation.map(group => ({
|
||||
...group,
|
||||
items: group.items.map(item => {
|
||||
let dynamicCount: string | undefined
|
||||
function navigate(itemRoute?: string) {
|
||||
if (!itemRoute) return
|
||||
emit('close')
|
||||
router.push(itemRoute)
|
||||
}
|
||||
|
||||
switch (item.label) {
|
||||
case 'Agenten':
|
||||
case 'Hosts · OpenClaw':
|
||||
dynamicCount = String(agentStore.agentList.length)
|
||||
break
|
||||
case 'Task Board':
|
||||
dynamicCount = String(taskStore.taskList.length)
|
||||
break
|
||||
case 'Kosten & Tokens':
|
||||
dynamicCount = agentStore.todayCost
|
||||
break
|
||||
case 'Docs & .md':
|
||||
dynamicCount = '0'
|
||||
break
|
||||
case 'Incidents':
|
||||
dynamicCount = '0'
|
||||
break
|
||||
}
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
count: dynamicCount ?? item.count,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
const statusLabel = computed(() => {
|
||||
if (liveSync.connected) return 'Live'
|
||||
if (liveSync.connecting) return 'Verbinde…'
|
||||
return 'Polling'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="['sidebar', { open: mobileOpen }]">
|
||||
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
|
||||
<aside :class="['rail', { open: mobileOpen }]">
|
||||
<!-- Brand -->
|
||||
<div class="side-top">
|
||||
<div class="brand-mark" v-html="icons.command || ''"></div>
|
||||
<div>
|
||||
<div class="brand-name">NEXUS</div>
|
||||
<div class="brand-sub">Mission Control</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="rail-brand" @click="navigate('/dashboard')">
|
||||
<span class="brand-mark" v-html="svg('command')"></span>
|
||||
<span class="rail-label brand-label">NEXUS</span>
|
||||
</button>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="nav">
|
||||
<NavGroup
|
||||
v-for="(group, idx) in dynamicNavigation"
|
||||
:key="idx"
|
||||
:label="group.group"
|
||||
:items="group.items"
|
||||
/>
|
||||
<nav class="rail-nav v2-scroll">
|
||||
<button
|
||||
v-for="item in railNav"
|
||||
:key="item.route"
|
||||
:class="['rail-item', { active: isActive(item.route) }]"
|
||||
:title="item.label"
|
||||
@click="navigate(item.route)"
|
||||
>
|
||||
<span class="rail-icon" v-html="svg(item.icon)"></span>
|
||||
<span
|
||||
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
|
||||
class="rail-dot"
|
||||
></span>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
|
||||
class="rail-count"
|
||||
>{{ notificationStore.unreadCount }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="side-foot">
|
||||
<div class="avatar">{{ ownerInitials }}</div>
|
||||
<div class="owner-info">
|
||||
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
|
||||
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
|
||||
<div class="rail-foot">
|
||||
<div class="rail-item static" :title="statusLabel">
|
||||
<span class="rail-icon">
|
||||
<span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
|
||||
</span>
|
||||
<span class="rail-label dim">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="item in railFooterNav"
|
||||
:key="item.route"
|
||||
:class="['rail-item', { active: isActive(item.route) }]"
|
||||
:title="item.label"
|
||||
@click="navigate(item.route)"
|
||||
>
|
||||
<span class="rail-icon" v-html="svg(item.icon)"></span>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
</button>
|
||||
|
||||
<div class="rail-owner">
|
||||
<span class="avatar">{{ ownerInitials }}</span>
|
||||
<span class="rail-label owner-label">
|
||||
<span class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</span>
|
||||
<span class="owner-role">{{ auth.user?.role ?? 'Owner' }}</span>
|
||||
</span>
|
||||
<button class="logout-btn rail-label" title="Abmelden" @click="logout" v-html="svg('logout')"></button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
flex: 0 0 248px;
|
||||
height: 100vh;
|
||||
.rail {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 68px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
|
||||
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
||||
border-right: 1px solid var(--line);
|
||||
backdrop-filter: blur(14px);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
overflow: hidden;
|
||||
transition: width .18s ease, box-shadow .18s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.side-top {
|
||||
.rail:hover {
|
||||
width: 232px;
|
||||
box-shadow: 24px 0 60px -30px rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
/* Labels: unsichtbar bis die Rail expandiert */
|
||||
.rail-label {
|
||||
opacity: 0;
|
||||
white-space: nowrap;
|
||||
transition: opacity .14s ease .04s;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rail:hover .rail-label,
|
||||
.rail.open .rail-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Brand ── */
|
||||
.rail-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 18px 18px 16px;
|
||||
gap: 13px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 38px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--grad);
|
||||
box-shadow: var(--glow-purple);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-mark :deep(svg) {
|
||||
@@ -142,108 +183,154 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
.brand-label {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
font-size: 16px;
|
||||
letter-spacing: .14em;
|
||||
line-height: 1;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--tx-3);
|
||||
letter-spacing: .05em;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
/* ── Nav ── */
|
||||
.rail-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 3px;
|
||||
padding: 6px 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
.rail-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 13px;
|
||||
height: 42px;
|
||||
padding: 0 13px;
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
|
||||
.side-foot:hover {
|
||||
background: rgba(124,108,255,.06);
|
||||
.rail-item:not(.static):hover {
|
||||
background: rgba(124, 108, 255, .08);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: none;
|
||||
.rail-item.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, rgba(124, 108, 255, .22), rgba(124, 108, 255, .04));
|
||||
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, .25);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 100vh;
|
||||
width: 280px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.rail-item.static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.rail-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: .9;
|
||||
}
|
||||
|
||||
.sidebar-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 12px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
}
|
||||
.rail-icon :deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sidebar-close:hover {
|
||||
background: rgba(124,108,255,.1);
|
||||
color: var(--tx);
|
||||
}
|
||||
.rail-item .rail-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar-close :deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
/* Ungelesen-Punkt am Icon (collapsed sichtbar) */
|
||||
.rail-dot {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
top: 9px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--st-block);
|
||||
box-shadow: 0 0 8px rgba(251, 113, 133, .8);
|
||||
}
|
||||
|
||||
.rail-count {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 20px;
|
||||
background: rgba(251, 113, 133, .16);
|
||||
border: 1px solid rgba(251, 113, 133, .35);
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.rail-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 6px 12px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.on {
|
||||
background: var(--st-work);
|
||||
animation: pulse-work 1.8s infinite;
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: var(--st-idle);
|
||||
}
|
||||
|
||||
.rail-label.dim {
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rail-owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 7px 5px 2px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
border-radius: 10px;
|
||||
background: var(--grad-soft);
|
||||
border: 1px solid var(--line-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
color: var(--tx);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.owner-info {
|
||||
min-width: 0;
|
||||
.owner-label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.owner-name {
|
||||
@@ -258,7 +345,41 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
||||
.owner-role {
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
margin-top: 1px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-3);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
color: var(--st-block);
|
||||
background: rgba(251, 113, 133, .1);
|
||||
}
|
||||
|
||||
.logout-btn :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ── Mobile: Drawer ── */
|
||||
@media (max-width: 767px) {
|
||||
.rail {
|
||||
position: fixed;
|
||||
width: 232px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform .22s ease;
|
||||
}
|
||||
|
||||
.rail.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { icons } from '../../composables/icons'
|
||||
|
||||
defineProps<{
|
||||
connected?: boolean
|
||||
statusLabel?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-sidebar': []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<!-- Hamburger (mobile only) -->
|
||||
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="search">
|
||||
<span class="search-icon" v-html="icons.search || ''"></span>
|
||||
<span class="search-placeholder">Operationen, Agents oder Tasks suchen…</span>
|
||||
</div>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- Status Pill -->
|
||||
<span :class="['pill', connected ? 'live' : 'preview']">
|
||||
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
|
||||
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
|
||||
</span>
|
||||
|
||||
<!-- Ask Iris Button -->
|
||||
<button class="btn btn-primary ask-iris-btn">
|
||||
<span class="btn-icon" v-html="icons.spark || ''"></span>
|
||||
<span class="ask-label">Ask Iris</span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
height: 62px;
|
||||
flex: 0 0 62px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(8,6,20,.5);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border-radius: 11px;
|
||||
background: rgba(124,108,255,.06);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--tx-3);
|
||||
font-size: 13.5px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
.search-icon :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 11px;
|
||||
border-radius: 20px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
border: 1px solid var(--line-2);
|
||||
background: rgba(124,108,255,.07);
|
||||
color: var(--tx-2);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.status-dot.on {
|
||||
background: var(--st-work);
|
||||
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
|
||||
animation: pulse-work 1.8s infinite;
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: var(--st-idle);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 10px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: filter .16s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-icon :deep(svg) {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.topbar {
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.search {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 9px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.hamburger:hover {
|
||||
background: rgba(124,108,255,.1);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.hamburger :deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ask-iris-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.ask-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ask-iris-btn .btn-icon {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ActivityTimeline — Wiederverwendbare Aktivitäts-Feed-Komponente
|
||||
*
|
||||
* Standardisierte Timeline-Darstellung für Task-Aktivität,
|
||||
* Agent-Aktivität und allgemeine Ereignis-Feeds.
|
||||
* Verwendet ausschließlich nexus-tokens.css Variablen.
|
||||
*/
|
||||
|
||||
export interface ActivityEntry {
|
||||
id?: string
|
||||
message: string
|
||||
timestamp?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<{
|
||||
entries: ActivityEntry[]
|
||||
loading?: boolean
|
||||
emptyLabel?: string
|
||||
}>(), {
|
||||
emptyLabel: 'Noch keine Aktivität',
|
||||
})
|
||||
|
||||
function formatDate(date?: string | null): string {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-timeline">
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="activity-empty">Lade…</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!entries.length" class="activity-empty">{{ emptyLabel }}</div>
|
||||
|
||||
<!-- Entries -->
|
||||
<article v-for="(entry, index) in entries" :key="entry.id ?? index" class="activity-item">
|
||||
<div class="activity-dot"></div>
|
||||
<div class="activity-body">
|
||||
<div class="activity-message">{{ entry.message }}</div>
|
||||
<div v-if="entry.timestamp" class="activity-time">
|
||||
{{ formatDate(entry.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.activity-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.activity-empty {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
font-style: italic;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
margin-top: 5px;
|
||||
background: var(--grad);
|
||||
box-shadow: 0 0 0 4px rgba(124, 108, 255, .12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activity-message {
|
||||
color: var(--tx);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
color: var(--tx-3);
|
||||
font-size: 10.5px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Badge — Nexus V2 Status- und Label-Badge
|
||||
*
|
||||
* Erweitert: zusätzliche nexus-token-basierte Varianten für
|
||||
* Agenten-Rollen, Status-Pills und Prioritäten.
|
||||
* Original shadcn-Varianten bleiben erhalten.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -8,10 +15,25 @@ const badgeVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Original shadcn
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
|
||||
// Nexus V2 — Token-basierte Varianten (Farben aus nexus-tokens.css)
|
||||
work: 'nexus-badge-work',
|
||||
think: 'nexus-badge-think',
|
||||
blocked: 'nexus-badge-blocked',
|
||||
queue: 'nexus-badge-queue',
|
||||
done: 'nexus-badge-done',
|
||||
review: 'nexus-badge-review',
|
||||
iris: 'nexus-badge-iris',
|
||||
bao: 'nexus-badge-bao',
|
||||
agent: 'nexus-badge-agent',
|
||||
priorityHigh: 'nexus-badge-prio-high',
|
||||
priorityMed: 'nexus-badge-prio-med',
|
||||
priorityLow: 'nexus-badge-prio-low',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -35,3 +57,78 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Nexus V2 Token-basierte Badge-Varianten */
|
||||
.nexus-badge-work {
|
||||
border-color: rgba(61, 220, 151, .25);
|
||||
background: rgba(34, 197, 94, .12);
|
||||
color: var(--st-work);
|
||||
}
|
||||
|
||||
.nexus-badge-think {
|
||||
border-color: rgba(52, 214, 245, .25);
|
||||
background: rgba(52, 214, 245, .1);
|
||||
color: var(--st-think);
|
||||
}
|
||||
|
||||
.nexus-badge-blocked {
|
||||
border-color: rgba(251, 113, 133, .25);
|
||||
background: rgba(244, 63, 94, .12);
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
.nexus-badge-queue {
|
||||
border-color: rgba(251, 191, 36, .25);
|
||||
background: rgba(251, 191, 36, .12);
|
||||
color: var(--st-queue);
|
||||
}
|
||||
|
||||
.nexus-badge-done {
|
||||
border-color: rgba(34, 197, 94, .25);
|
||||
background: rgba(34, 197, 94, .12);
|
||||
color: var(--st-work);
|
||||
}
|
||||
|
||||
.nexus-badge-review {
|
||||
border-color: rgba(249, 115, 22, .25);
|
||||
background: rgba(249, 115, 22, .12);
|
||||
color: var(--clr-review);
|
||||
}
|
||||
|
||||
.nexus-badge-iris {
|
||||
border-color: rgba(147, 51, 234, .25);
|
||||
background: rgba(147, 51, 234, .12);
|
||||
color: var(--clr-iris);
|
||||
}
|
||||
|
||||
.nexus-badge-bao {
|
||||
border-color: rgba(59, 130, 246, .25);
|
||||
background: rgba(59, 130, 246, .12);
|
||||
color: var(--clr-bao);
|
||||
}
|
||||
|
||||
.nexus-badge-agent {
|
||||
border-color: rgba(16, 185, 129, .2);
|
||||
background: rgba(16, 185, 129, .12);
|
||||
color: var(--clr-agent);
|
||||
}
|
||||
|
||||
.nexus-badge-prio-high {
|
||||
border-color: var(--st-block);
|
||||
background: transparent;
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
.nexus-badge-prio-med {
|
||||
border-color: var(--st-queue);
|
||||
background: transparent;
|
||||
color: var(--st-queue);
|
||||
}
|
||||
|
||||
.nexus-badge-prio-low {
|
||||
border-color: var(--a-blue);
|
||||
background: transparent;
|
||||
color: var(--a-blue);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Card — Nexus V2 glass-panel Karte
|
||||
*
|
||||
* Verwendet nexus-tokens.css als Single Source of Truth.
|
||||
* Hintergrund-kompatibel mit shadcn-Card-Props.
|
||||
* Wrapped content in .glass-panel styles aus tokens.css.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
/** Variante: 'glass' (default) | 'raised' | 'subtle' */
|
||||
variant?: 'glass' | 'raised' | 'subtle'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'glass',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||
<div
|
||||
:class="cn(
|
||||
'rounded-xl border',
|
||||
{
|
||||
'glass-panel': variant === 'glass',
|
||||
'card-raised': variant === 'raised',
|
||||
'card-subtle': variant === 'subtle',
|
||||
},
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* glass-panel ist in nexus-tokens.css definiert */
|
||||
|
||||
.card-raised {
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.card-subtle {
|
||||
background: rgba(255, 255, 255, .02);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EmptyState — Standardisierte Leerzustands-Anzeige
|
||||
*
|
||||
* Konsistente Darstellung für "keine Daten"-Zustände im Dashboard,
|
||||
* TaskBoard und allen V2-Views.
|
||||
* Verwendet ausschließlich nexus-tokens.css Variablen.
|
||||
*/
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** Icon-Klasse (optional, z.B. für SVG-Nutzung) */
|
||||
icon?: string
|
||||
/** Primärer Text */
|
||||
title?: string
|
||||
/** Sekundärer Erklärungstext */
|
||||
description?: string
|
||||
/** Kompakte Darstellung (inline-flex) */
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
title: 'Keine Einträge',
|
||||
compact: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state" :class="{ compact }">
|
||||
<div v-if="icon" class="empty-icon" v-html="icon"></div>
|
||||
<slot name="icon">
|
||||
<div v-if="!icon" class="empty-icon-default">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect x="4" y="6" width="20" height="16" rx="3" stroke="currentColor" stroke-width="1.2" />
|
||||
<line x1="10" y1="12" x2="18" y2="12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
<line x1="10" y1="16" x2="15" y2="16" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
</slot>
|
||||
<div v-if="!compact" class="empty-title">{{ title }}</div>
|
||||
<div v-if="description" class="empty-desc">{{ description }}</div>
|
||||
<div v-if="$slots.action" class="empty-action">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.empty-icon,
|
||||
.empty-icon-default {
|
||||
color: var(--tx-3);
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.compact .empty-icon,
|
||||
.compact .empty-icon-default {
|
||||
margin-bottom: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty-icon-default {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--tx-2);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
.compact .empty-title {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
max-width: 280px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* PageHeading — Standardisierter Seiten-Header für V2 Views
|
||||
*
|
||||
* Bietet konsistentes grad-text Styling, Eyebrow/Subtitle und Action-Slot.
|
||||
* Verwendet ausschließlich nexus-tokens.css Variablen.
|
||||
*/
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** Gradient-Text Überschrift */
|
||||
title: string
|
||||
/** Kleiner Eyebrow-Text über der Überschrift (violett) */
|
||||
eyebrow?: string
|
||||
/** Subtitle unter der Überschrift */
|
||||
subtitle?: string
|
||||
}>(), {})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<p v-if="eyebrow || $slots.eyebrow" class="eyebrow">
|
||||
<slot name="eyebrow">{{ eyebrow }}</slot>
|
||||
</p>
|
||||
<h1><span class="grad-text">{{ title }}</span></h1>
|
||||
<p v-if="subtitle" class="board-subtitle">{{ subtitle }}</p>
|
||||
<slot name="meta" />
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="heading-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--a-mid);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.board-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
.grad-text {
|
||||
background: var(--grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.heading-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SectionHeader — Titelzeile für Sektionen/Spalten im Dashboard V2
|
||||
*
|
||||
* Kapselt wiederholtes Muster: dot + label + count aus TaskBoard-Spalten
|
||||
* und Iris-Panel-Sektionen. Verwendet ausschließlich Token-Farben.
|
||||
*/
|
||||
|
||||
withDefaults(defineProps<{
|
||||
label: string
|
||||
/** Status-Farbe des Dots: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao' */
|
||||
dotColor?: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao'
|
||||
/** Numerischer Count (rechts) */
|
||||
count?: number
|
||||
/** Variante: 'column' (Spalten-Header) oder 'section' (Iris-Panel) */
|
||||
variant?: 'column' | 'section'
|
||||
}>(), {
|
||||
variant: 'column',
|
||||
})
|
||||
|
||||
function dotColorVar(color: string): string {
|
||||
const map: Record<string, string> = {
|
||||
work: 'var(--st-work)',
|
||||
think: 'var(--st-think)',
|
||||
idle: 'var(--st-idle)',
|
||||
block: 'var(--st-block)',
|
||||
queue: 'var(--st-queue)',
|
||||
review: 'var(--st-queue)', // orange-ähnlich
|
||||
iris: 'var(--a-purple)',
|
||||
bao: 'var(--a-blue)',
|
||||
}
|
||||
return map[color] || 'var(--st-idle)'
|
||||
}
|
||||
|
||||
function dotRingVar(color: string): string {
|
||||
const map: Record<string, string> = {
|
||||
work: 'rgba(61, 220, 151, .25)',
|
||||
think: 'rgba(52, 214, 245, .25)',
|
||||
idle: 'rgba(107, 103, 150, .25)',
|
||||
block: 'rgba(251, 113, 133, .25)',
|
||||
queue: 'rgba(251, 191, 36, .25)',
|
||||
review: 'rgba(251, 146, 60, .25)',
|
||||
iris: 'rgba(181, 87, 246, .25)',
|
||||
bao: 'rgba(79, 124, 255, .25)',
|
||||
}
|
||||
return map[color] || 'rgba(107, 103, 150, .25)'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-header" :class="variant">
|
||||
<span
|
||||
class="section-dot"
|
||||
:style="{
|
||||
background: dotColorVar(dotColor || 'idle'),
|
||||
boxShadow: `0 0 0 2px ${dotRingVar(dotColor || 'idle')}`,
|
||||
}"
|
||||
></span>
|
||||
<span class="section-label">{{ label }}</span>
|
||||
<span v-if="count !== undefined" class="section-count">{{ count }}</span>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-header.column {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section-header.section {
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.section-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .06em;
|
||||
color: var(--tx-2);
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
.section-header.section .section-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.section-count {
|
||||
margin-left: auto;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--glass-2);
|
||||
color: var(--tx-2);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SkeletonLoader — Platzhalter-Animation für Ladezustände
|
||||
*
|
||||
* Extrahiert aus dem Muster in TaskStrip (skeleton-pulse) und
|
||||
* board-loading spinner. Zentrale Komponente mit zwei Varianten:
|
||||
*
|
||||
* - 'card' — Rechteckiger Karten-Skeleton
|
||||
* - 'text' — Zeilen-Skeleton für Text
|
||||
* - 'circle' — Runder Skeleton (Avatar, Dot)
|
||||
*
|
||||
* Alle Farben aus nexus-tokens.css Token.
|
||||
*/
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** Darstellungsform */
|
||||
variant?: 'card' | 'text' | 'circle'
|
||||
/** Höhe in px (nur für card/text) */
|
||||
height?: number
|
||||
/** Breite in px (optional, sonst 100%) */
|
||||
width?: number
|
||||
}>(), {
|
||||
variant: 'card',
|
||||
height: 78,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="skeleton"
|
||||
:class="variant"
|
||||
:style="{
|
||||
height: variant === 'circle' ? `${width || height || 32}px` : `${height}px`,
|
||||
width: width ? `${width}px` : '100%',
|
||||
}"
|
||||
>
|
||||
<div v-if="variant === 'text'" class="skeleton-line" style="width: 60%"></div>
|
||||
<div v-if="variant === 'text'" class="skeleton-line" style="width: 85%; margin-top: 8px"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skeleton {
|
||||
border-radius: var(--r-sm, 10px);
|
||||
background: var(--glass);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(124, 108, 255, .06) 40%,
|
||||
rgba(124, 108, 255, .12) 50%,
|
||||
rgba(124, 108, 255, .06) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton.text {
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 10px;
|
||||
border-radius: 5px;
|
||||
background: var(--glass-2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skeleton-line::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(124, 108, 255, .08) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
animation: skeleton-shimmer 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(180%); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StatusDot — Nexus V2 animierter Status-Indikator
|
||||
*
|
||||
* Kapselt die pulse-keyframes aus nexus-tokens.css.
|
||||
* Status-Farben und Pulse-Animationen stammen ausschließlich aus Tokens.
|
||||
*
|
||||
* Props:
|
||||
* status – 'work' | 'think' | 'idle' | 'block' | 'queue'
|
||||
* size – 'sm' (8px) | 'md' (9px) | 'lg' (12px), default 'md'
|
||||
* pulse – Animation aktiv (default: true für work/think/block)
|
||||
* label – Text-Label rechts neben dem Dot (optional)
|
||||
*/
|
||||
|
||||
withDefaults(defineProps<{
|
||||
status: 'work' | 'think' | 'idle' | 'block' | 'queue'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
pulse?: boolean
|
||||
label?: string
|
||||
}>(), {
|
||||
size: 'md',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-dot-wrapper">
|
||||
<span
|
||||
class="dot"
|
||||
:class="[status, size, { pulse: pulse !== false }]"
|
||||
:aria-label="label || status"
|
||||
role="status"
|
||||
></span>
|
||||
<span v-if="label" class="dot-label">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-dot-wrapper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dot.sm { width: 7px; height: 7px; }
|
||||
.dot.md { width: 9px; height: 9px; }
|
||||
.dot.lg { width: 12px; height: 12px; }
|
||||
|
||||
/* Farben aus nexus-tokens.css — Single Source of Truth */
|
||||
.dot.work { background: var(--st-work); }
|
||||
.dot.think { background: var(--st-think); }
|
||||
.dot.idle { background: var(--st-idle); }
|
||||
.dot.block { background: var(--st-block); }
|
||||
.dot.queue { background: var(--st-queue); }
|
||||
|
||||
/* Pulse-Animationen (Keyframes in tokens.css definiert) */
|
||||
.dot.work.pulse { animation: pulse-work 1.8s infinite; box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
|
||||
.dot.think.pulse { animation: pulse-think 1.8s infinite; box-shadow: 0 0 0 0 rgba(52,214,245,.55); }
|
||||
.dot.block.pulse { animation: pulse-block 1.4s infinite; box-shadow: 0 0 0 0 rgba(251,113,133,.55); }
|
||||
|
||||
.dot-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--tx-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* StatusPill — Einheitliche State-Pill für Statusanzeigen
|
||||
*
|
||||
* Extrahiert aus dem doppelten Muster in TaskBoardView (detail-state-pill)
|
||||
* und BoardCard (child-state). Verwendet ausschließlich Token aus
|
||||
* nexus-tokens.css als Farbquelle.
|
||||
*
|
||||
* Variants:
|
||||
* backlog | progress | review | blocked | done
|
||||
*
|
||||
* Optional: size 'sm' (kompakt) oder 'md' (default).
|
||||
*/
|
||||
|
||||
withDefaults(defineProps<{
|
||||
/** Status-Variante */
|
||||
variant: 'backlog' | 'progress' | 'review' | 'blocked' | 'done'
|
||||
/** Grösse: 'sm' oder 'md' */
|
||||
size?: 'sm' | 'md'
|
||||
}>(), {
|
||||
size: 'md',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-pill" :class="[variant, size]">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .03em;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.status-pill.md { padding: 3px 9px; font-size: 10.5px; }
|
||||
.status-pill.sm { padding: 1px 6px; font-size: 8.5px; text-transform: uppercase; }
|
||||
|
||||
/* Variants — alle Farben aus nexus-tokens.css */
|
||||
.status-pill.backlog { color: var(--pill-backlog); background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .25); }
|
||||
.status-pill.progress { color: var(--pill-progress); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
|
||||
.status-pill.review { color: var(--pill-review); background: rgba(249, 115, 22, .12); border-color: rgba(249, 115, 22, .25); }
|
||||
.status-pill.blocked { color: var(--pill-blocked); background: rgba(244, 63, 94, .12); border-color: rgba(244, 63, 94, .25); }
|
||||
.status-pill.done { color: var(--pill-done); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
|
||||
</style>
|
||||
@@ -7,18 +7,18 @@ const { toasts, remove } = useToast()
|
||||
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
color: '#22c55e',
|
||||
bg: 'rgba(34, 197, 94, 0.10)',
|
||||
color: 'var(--st-work)',
|
||||
bg: 'rgba(61, 220, 151, 0.10)',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: '#ef4444',
|
||||
bg: 'rgba(239, 68, 68, 0.10)',
|
||||
color: 'var(--st-block)',
|
||||
bg: 'rgba(251, 113, 133, 0.10)',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: '#3b82f6',
|
||||
bg: 'rgba(59, 130, 246, 0.10)',
|
||||
color: 'var(--a-blue)',
|
||||
bg: 'rgba(79, 124, 255, 0.10)',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -76,7 +76,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
|
||||
pointer-events: auto;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s;
|
||||
@@ -114,7 +114,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
/* Transition animations */
|
||||
|
||||
@@ -4,25 +4,35 @@ import { type VariantProps, cva } from 'class-variance-authority'
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// shadcn-original
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
|
||||
// Nexus V2 — Token-basierte Varianten
|
||||
/** CTA / primäre Aktion — Gradient */
|
||||
gradient: 'nexus-btn-gradient',
|
||||
/** Icon-Only — quadratischer Button ohne Text */
|
||||
icon: 'nexus-btn-icon',
|
||||
/** Ghost thin — minimal hover */
|
||||
ghostSubtle: 'nexus-btn-ghost-subtle',
|
||||
/** Danger/Blocker — rote Akzent-Aktion */
|
||||
danger: 'nexus-btn-danger',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
iconSm: 'h-7 w-7 rounded-md',
|
||||
pill: 'h-7 px-4 rounded-full text-xs',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export { default as Badge } from './Badge.vue'
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as StatusDot } from './StatusDot.vue'
|
||||
export { default as PageHeading } from './PageHeading.vue'
|
||||
export { default as EmptyState } from './EmptyState.vue'
|
||||
export { default as ActivityTimeline } from './ActivityTimeline.vue'
|
||||
export { default as SectionHeader } from './SectionHeader.vue'
|
||||
export { default as StatusPill } from './StatusPill.vue'
|
||||
export { default as SkeletonLoader } from './SkeletonLoader.vue'
|
||||
export { default as Input } from './Input.vue'
|
||||
export { default as Textarea } from './Textarea.vue'
|
||||
export { default as Select } from './Select.vue'
|
||||
export { default as Dialog } from './Dialog.vue'
|
||||
export { default as ToastContainer } from './ToastContainer.vue'
|
||||
export { Button } from './button'
|
||||
@@ -25,6 +25,10 @@ export const icons: Record<string, string> = {
|
||||
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
|
||||
plus: `<path d="M12 5v14M5 12h14"/>`,
|
||||
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
|
||||
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
|
||||
bell: `<path d="M18 9a6 6 0 1 0-12 0c0 6-2.5 7-2.5 7h17S18 15 18 9M10.3 20a2 2 0 0 0 3.4 0"/>`,
|
||||
calendar: `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>`,
|
||||
logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>`,
|
||||
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
|
||||
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
|
||||
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
|
||||
@@ -50,40 +54,22 @@ export interface NavGroupDef {
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation structure matching NEXUS.nav from agents.js
|
||||
* Rail-Navigation — EINE Quelle für alle Seiten (Dashboard + Rest).
|
||||
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
|
||||
* unten im Fußbereich (railFooterNav).
|
||||
*/
|
||||
export const navigation: NavGroupDef[] = [
|
||||
{
|
||||
group: 'Operations',
|
||||
items: [
|
||||
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
|
||||
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
||||
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
||||
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Knowledge',
|
||||
items: [
|
||||
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
||||
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
|
||||
{ icon: 'search', label: 'Research', route: '/research' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Infrastructure',
|
||||
items: [
|
||||
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
|
||||
{ icon: 'model', label: 'Modelle', route: '/models' },
|
||||
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Governance',
|
||||
items: [
|
||||
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
|
||||
{ icon: 'shield', label: 'Security', route: '/security' },
|
||||
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
||||
],
|
||||
},
|
||||
export const railNav: NavItemDef[] = [
|
||||
{ icon: 'grid', label: 'Dashboard', route: '/dashboard' },
|
||||
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
||||
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
||||
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
||||
{ icon: 'doc', label: 'Docs', route: '/docs' },
|
||||
{ icon: 'calendar', label: 'Kalender', route: '/calendar' },
|
||||
{ icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
|
||||
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
||||
{ icon: 'shield', label: 'Security', route: '/security' },
|
||||
]
|
||||
|
||||
export const railFooterNav: NavItemDef[] = [
|
||||
{ icon: 'gear', label: 'Einstellungen', route: '/settings' },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* useConfirm — Wiederverwendbare Confirmation-Dialog-Logik
|
||||
*
|
||||
* Extrahiert aus dem Modal-Muster in TaskBoardView
|
||||
* (showCreateModal / showChangesModal / showDetailPanel),
|
||||
* das sich auch in anderen Views wiederholt.
|
||||
*
|
||||
* Bietet reaktiven open/close-State, Form-Fehler-Management
|
||||
* und Escape-Key-Bindung.
|
||||
*/
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export function useConfirm() {
|
||||
const isOpen = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
function open() {
|
||||
isOpen.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function setError(msg: string) {
|
||||
error.value = msg
|
||||
success.value = ''
|
||||
}
|
||||
|
||||
function setSuccess(msg: string) {
|
||||
success.value = msg
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
/** Escape-Taste schliesst den Dialog */
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && isOpen.value) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Overlay-Klick ausserhalb schliesst */
|
||||
function onOverlayClick(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('modal-overlay')) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Sperrt Body-Scroll, wenn Dialog offen */
|
||||
watch(isOpen, (open) => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
})
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
error,
|
||||
success,
|
||||
submitting,
|
||||
open,
|
||||
close,
|
||||
setError,
|
||||
setSuccess,
|
||||
onOverlayClick,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* useFormatDate — Einheitliche Datums-/Zeitformatierung für Nexus V2
|
||||
*
|
||||
* Kapselt die mehrfach wiederholten formatDate-, relativeTime- und
|
||||
* toDateInputValue-Helper, die bisher in TaskBoardView, BoardCard,
|
||||
* mit anderen Views dupliziert waren.
|
||||
*
|
||||
* Alle Ausgaben orientieren sich an de-DE Locale.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Datum als lesbaren String (de-DE, kurzes/medium-DateStyle).
|
||||
*/
|
||||
export function formatDate(date?: string | null, withTime = false): string {
|
||||
if (!date) return '—'
|
||||
return new Date(date).toLocaleString('de-DE', withTime
|
||||
? { dateStyle: 'medium', timeStyle: 'short' }
|
||||
: { dateStyle: 'medium' })
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO-Datumsstring in YYYY-MM-DD für <input type="date">.
|
||||
*/
|
||||
export function toDateInputValue(date?: string | null): string {
|
||||
if (!date) return ''
|
||||
return new Date(date).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative Zeit ("gerade eben", "vor 5 min", "vor 3 h", "vor 2 d").
|
||||
* Optionaler Fallback, wenn date fehlt.
|
||||
*/
|
||||
export function relativeTime(date?: string | null, fallback = 'keine Aktivität'): string {
|
||||
if (!date) return fallback
|
||||
const diffMs = Date.now() - new Date(date).getTime()
|
||||
const mins = Math.max(0, Math.round(diffMs / 60000))
|
||||
if (mins < 1) return 'gerade eben'
|
||||
if (mins < 60) return `vor ${mins} min`
|
||||
const hours = Math.round(mins / 60)
|
||||
if (hours < 24) return `vor ${hours} h`
|
||||
const days = Math.round(hours / 24)
|
||||
return `vor ${days} d`
|
||||
}
|
||||
|
||||
/**
|
||||
* Minuten seit einem Datum (oder Infinity falls kein Datum).
|
||||
*/
|
||||
export function minutesSince(dateStr?: string | null): number {
|
||||
if (!dateStr) return Infinity
|
||||
return (Date.now() - new Date(dateStr).getTime()) / 60000
|
||||
}
|
||||
|
||||
/**
|
||||
* Stunden seit einem Datum (gerundet).
|
||||
*/
|
||||
export function hoursSince(dateStr: string): number {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
return Math.round((now - then) / 3600000)
|
||||
}
|
||||
@@ -1,49 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* NexusLayout — V2 Dashboard Shell
|
||||
* Flex row, 100vh, overflow hidden.
|
||||
* Sidebar (248px) + Main (flex:1, flex-column)
|
||||
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
|
||||
* NexusLayout — gemeinsame Shell für ALLE Seiten
|
||||
*
|
||||
* Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
|
||||
* Content bekommt die volle restliche Fläche:
|
||||
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
|
||||
* - alle anderen: scrollbarer Container mit Seiten-Padding
|
||||
*
|
||||
* Die Live-Verbindung (SSE) gehört der Shell — EINE Verbindung für die
|
||||
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useDashboardStore } from '../stores/dashboard'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||
import Sidebar from '../components/layout/Sidebar.vue'
|
||||
import Topbar from '../components/layout/Topbar.vue'
|
||||
import { svg } from '../composables/icons'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const liveSync = useLiveSyncStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
/* ── Mobile Sidebar State ───────────────────────── */
|
||||
const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
|
||||
|
||||
/* ── Mobile Drawer ─────────────────────────────── */
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
|
||||
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
|
||||
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
|
||||
liveSync.connect(liveUser.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
liveSync.reconnectNow()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
liveSync.connect(liveUser.value)
|
||||
notificationStore.startPolling()
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
window.addEventListener('online', onOnline)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
liveSync.disconnect()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('online', onOnline)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nexus-layout">
|
||||
<GalaxyBackground />
|
||||
<Sidebar
|
||||
:mobile-open="mobileMenuOpen"
|
||||
@close="closeMobileMenu"
|
||||
/>
|
||||
|
||||
<!-- Mobile Backdrop -->
|
||||
<div
|
||||
v-if="mobileMenuOpen"
|
||||
class="mobile-backdrop"
|
||||
@click="closeMobileMenu"
|
||||
></div>
|
||||
<div class="rail-slot">
|
||||
<Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Hamburger + Backdrop -->
|
||||
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
|
||||
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
|
||||
|
||||
<main class="nexus-main">
|
||||
<Topbar
|
||||
:connected="dashboardStore.isGatewayConnected"
|
||||
:status-label="dashboardStore.irisStatusLabel"
|
||||
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
|
||||
/>
|
||||
<div class="nexus-content">
|
||||
<div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
@@ -59,6 +89,15 @@ function closeMobileMenu() {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Platzhalter in der Flex-Reihe — die Rail selbst liegt absolut darüber
|
||||
und kann expandieren, ohne den Content zu verschieben. */
|
||||
.rail-slot {
|
||||
width: 68px;
|
||||
flex: 0 0 68px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.nexus-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -70,19 +109,62 @@ function closeMobileMenu() {
|
||||
|
||||
.nexus-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nexus-content.full-bleed {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nexus-content.full-bleed > :deep(*) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nexus-content.page-scroll {
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px 64px;
|
||||
}
|
||||
|
||||
.mobile-toggle,
|
||||
.mobile-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.rail-slot {
|
||||
width: 0;
|
||||
flex: 0 0 0;
|
||||
}
|
||||
|
||||
.nexus-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 90;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-toggle :deep(svg) {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -90,5 +172,9 @@ function closeMobileMenu() {
|
||||
z-index: 99;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.nexus-content.page-scroll {
|
||||
padding: 60px 16px 48px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+15
-19
@@ -19,31 +19,27 @@ const routes = [
|
||||
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
|
||||
// V2 Dashboard (neues NexusLayout + FlowBoard)
|
||||
// Eine Shell für alle Seiten (Rail-Navigation, app-weiter Live-Sync)
|
||||
{
|
||||
path: '/dashboard',
|
||||
path: '/',
|
||||
component: NexusLayout,
|
||||
children: [
|
||||
{ path: '', name: 'Dashboard', component: FlowBoard },
|
||||
{ path: 'dashboard', name: 'Dashboard', component: FlowBoard, meta: { fullBleed: true } },
|
||||
{ path: 'agents', name: 'Agents', component: AgentsIndexView },
|
||||
{ path: 'agents/:id', name: 'AgentDetail', component: AgentDetailView },
|
||||
{ path: 'tasks', name: 'Task Board', component: TaskBoardView },
|
||||
{ path: 'tasks/:id', name: 'TaskDetail', component: TaskDetailView },
|
||||
{ path: 'memory', name: 'Memory', component: MemoryView },
|
||||
{ path: 'docs', name: 'Docs', component: DocsView },
|
||||
{ path: 'calendar', name: 'Calendar', component: CalendarView },
|
||||
{ path: 'notifications', name: 'Notifications', component: NotificationsView },
|
||||
{ path: 'incidents', name: 'Incidents', component: IncidentsView },
|
||||
{ path: 'security', name: 'Security', component: SecurityView },
|
||||
{ path: 'projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
||||
{ path: 'settings', name: 'Settings', component: SettingsView },
|
||||
],
|
||||
},
|
||||
|
||||
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
|
||||
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
|
||||
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
|
||||
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
|
||||
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
|
||||
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
|
||||
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
|
||||
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
|
||||
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
|
||||
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
|
||||
{ path: '/models', name: 'Models', component: { template: '' } },
|
||||
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
||||
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
|
||||
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
|
||||
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { openDashboardLiveStream } from '../services/live'
|
||||
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
||||
import type { NotificationItem } from './notifications'
|
||||
import type { TaskItem } from '../components/dashboard/v2/types'
|
||||
import { useTaskStore } from './tasks'
|
||||
import { useNotificationStore } from './notifications'
|
||||
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
||||
|
||||
interface NotificationSnapshotDto {
|
||||
notifications: NotificationItem[]
|
||||
unreadCount: number
|
||||
forUser: string
|
||||
}
|
||||
|
||||
interface DashboardLiveSnapshotDto {
|
||||
board: BoardGroup
|
||||
notifications: NotificationSnapshotDto
|
||||
cursor: LiveCursorDto
|
||||
}
|
||||
|
||||
function isBoardGroup(value: unknown): value is BoardGroup {
|
||||
const v = value as BoardGroup
|
||||
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
||||
}
|
||||
|
||||
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
||||
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
||||
}
|
||||
|
||||
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
||||
return {
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
agent: t.assignedTo ?? '—',
|
||||
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
||||
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
||||
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
||||
detail: t.detail,
|
||||
source: t.source,
|
||||
}
|
||||
}
|
||||
|
||||
export const useLiveSyncStore = defineStore('liveSync', {
|
||||
state: () => ({
|
||||
connected: false,
|
||||
connecting: false,
|
||||
lastEventAt: null as string | null,
|
||||
lastHeartbeatAt: null as string | null,
|
||||
error: null as string | null,
|
||||
controller: null as AbortController | null,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
liveIndicatorLabel: (state) => {
|
||||
if (state.connecting) return 'Verbinde…'
|
||||
if (state.connected) return `Live · #${state.lastSequence}`
|
||||
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
||||
},
|
||||
connectionHealth: (state) => {
|
||||
if (state.connected) return 'healthy'
|
||||
if (state.connecting) return 'connecting'
|
||||
return 'degraded'
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async connect(forUser = 'bao') {
|
||||
if (this.connecting || this.connected) return
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
this.controller = new AbortController()
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
try {
|
||||
const stream = await openDashboardLiveStream((event, data) => {
|
||||
this.lastEventAt = new Date().toISOString()
|
||||
|
||||
if (event === 'heartbeat') {
|
||||
const cursor = data as LiveCursorDto
|
||||
this.lastHeartbeatAt = cursor.timestamp
|
||||
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
||||
return
|
||||
}
|
||||
|
||||
if (event === 'snapshot') {
|
||||
const snapshot = data as DashboardLiveSnapshotDto
|
||||
taskStore.board = snapshot.board
|
||||
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
||||
notificationStore.notifications = snapshot.notifications.notifications
|
||||
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
||||
this.lastSequence = snapshot.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
return
|
||||
}
|
||||
|
||||
const eventDto = data as DashboardLiveEventDto
|
||||
this.applyEnvelope(eventDto.envelope, forUser)
|
||||
this.lastSequence = eventDto.cursor.sequence
|
||||
this.connected = true
|
||||
this.mode = 'live'
|
||||
this.reconnectAttempts = 0
|
||||
taskStore.stopBoardPolling()
|
||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||
|
||||
await stream.closed
|
||||
} catch (error) {
|
||||
if (this.controller?.signal.aborted) return
|
||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
||||
taskStore.board = envelope.payload
|
||||
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
||||
return
|
||||
}
|
||||
|
||||
if (envelope.type === 'notifications.snapshot') {
|
||||
const snapshot = envelope.payload as NotificationSnapshotDto
|
||||
if (snapshot.forUser !== forUser) return
|
||||
notificationStore.notifications = snapshot.notifications
|
||||
notificationStore.unreadCount = snapshot.unreadCount
|
||||
}
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.controller?.abort()
|
||||
this.controller = null
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.mode = 'polling'
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
scheduleReconnect(forUser = 'bao') {
|
||||
if (this.reconnectTimer) return
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect(forUser)
|
||||
}, delay)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -50,9 +50,11 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
error: null as string | null,
|
||||
controller: null as AbortController | null,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
watchdogTimer: null as ReturnType<typeof setInterval> | null,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
forUser: 'bao',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -73,7 +75,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
if (this.connecting || this.connected) return
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
this.forUser = forUser
|
||||
this.controller = new AbortController()
|
||||
this.startWatchdog()
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
@@ -113,19 +117,51 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||
|
||||
await stream.closed
|
||||
// Stream „sauber" beendet (Proxy-Timeout, Server-Neustart, Netzwechsel):
|
||||
// muss genauso wie ein Fehler behandelt werden — sonst bleibt connected=true
|
||||
// hängen, Polling ist gestoppt und die Seite erhält nie wieder Updates.
|
||||
} catch (error) {
|
||||
if (this.controller?.signal.aborted) return
|
||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
if (!this.controller?.signal.aborted) {
|
||||
console.warn('[liveSync] stream failed', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
}
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
|
||||
if (this.controller?.signal.aborted) return
|
||||
|
||||
this.connected = false
|
||||
this.mode = 'polling'
|
||||
taskStore.startBoardPolling()
|
||||
this.scheduleReconnect(forUser)
|
||||
},
|
||||
|
||||
/** Erzwingt einen frischen Stream (Watchdog / visibilitychange / online). */
|
||||
reconnectNow() {
|
||||
const forUser = this.forUser
|
||||
this.disconnect()
|
||||
this.connect(forUser)
|
||||
},
|
||||
|
||||
startWatchdog() {
|
||||
if (this.watchdogTimer) return
|
||||
this.watchdogTimer = setInterval(() => {
|
||||
if (!this.connected || !this.lastEventAt) return
|
||||
// Heartbeat kommt alle 20s — >65s Stille heißt: Verbindung ist tot,
|
||||
// auch wenn der Browser den fetch-Stream noch für offen hält.
|
||||
const silentMs = Date.now() - new Date(this.lastEventAt).getTime()
|
||||
if (silentMs > 65000) {
|
||||
console.warn('[liveSync] heartbeat timeout, reconnecting')
|
||||
this.reconnectNow()
|
||||
}
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
stopWatchdog() {
|
||||
if (this.watchdogTimer) {
|
||||
clearInterval(this.watchdogTimer)
|
||||
this.watchdogTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -148,6 +184,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.stopWatchdog()
|
||||
this.controller?.abort()
|
||||
this.controller = null
|
||||
this.connected = false
|
||||
@@ -161,7 +198,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
|
||||
scheduleReconnect(forUser = 'bao') {
|
||||
if (this.reconnectTimer) return
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
// Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
|
||||
// danach sanft hochstaffeln bis 30s.
|
||||
const delay = this.reconnectAttempts === 0 ? 1000 : Math.min(30000, 5000 * this.reconnectAttempts)
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
|
||||
@@ -2,6 +2,15 @@ import { defineStore } from 'pinia'
|
||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
export interface PendingApprovalTask {
|
||||
id: string
|
||||
title: string
|
||||
state: string
|
||||
priority: string
|
||||
projectId?: string | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const fallback: OperationsSnapshot = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtime: { runtime: 'OpenClaw', status: 'Unknown', detail: 'Awaiting connection…' },
|
||||
@@ -22,6 +31,11 @@ export const useOperationsStore = defineStore('operations', {
|
||||
connected: false,
|
||||
}),
|
||||
actions: {
|
||||
async fetchPendingApprovals(): Promise<PendingApprovalTask[]> {
|
||||
const response = await apiFetch('/api/v1/tasks/pending-approval')
|
||||
if (!response.ok) throw new Error('Pending approvals could not be loaded')
|
||||
return await response.json()
|
||||
},
|
||||
async createProject(name: string) {
|
||||
const response = await apiFetch('/api/v1/projects', {
|
||||
method: 'POST',
|
||||
@@ -145,7 +159,10 @@ export const useOperationsStore = defineStore('operations', {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}/approve`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be approved')
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: 'Task could not be approved' }))
|
||||
throw new Error(err.detail || 'Task could not be approved')
|
||||
}
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) {
|
||||
this.snapshot.tasks.splice(index, 1)
|
||||
@@ -161,7 +178,10 @@ export const useOperationsStore = defineStore('operations', {
|
||||
const response = await apiFetch(`/api/v1/tasks/${id}/reject`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) throw new Error('Task could not be rejected')
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: 'Task could not be rejected' }))
|
||||
throw new Error(err.detail || 'Task could not be rejected')
|
||||
}
|
||||
const index = this.snapshot.tasks.findIndex(task => task.id === id)
|
||||
if (index !== -1) {
|
||||
this.snapshot.tasks[index] = { ...this.snapshot.tasks[index], state: 'Backlog' }
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface DashboardTaskDto {
|
||||
lastActivityMessage?: string | null
|
||||
lastActivityAt?: string | null
|
||||
childTasks?: DashboardTaskDto[] | null
|
||||
doneChildTaskCount?: number
|
||||
childTaskCount?: number
|
||||
openChildTaskCount?: number
|
||||
hasVisibleDelegation?: boolean
|
||||
@@ -215,6 +216,29 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Review abnehmen (Review → Done) ─────── */
|
||||
async approveReview(id: string) {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/${id}/approve`, { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || `HTTP ${res.status}`)
|
||||
}
|
||||
await this.fetchBoard()
|
||||
},
|
||||
|
||||
/* ── API: Änderung anfordern (Review → Zielspalte) ── */
|
||||
async requestChanges(id: string, comment: string, targetState = 'In progress') {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/${id}/request-changes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ comment, targetState }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || `HTTP ${res.status}`)
|
||||
}
|
||||
await this.fetchBoard()
|
||||
},
|
||||
|
||||
/* ── API: Create task ─────────────────────────── */
|
||||
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
|
||||
try {
|
||||
@@ -297,6 +321,18 @@ export const useTaskStore = defineStore('tasks', {
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Kommentar/Aktivität an Task posten ──── */
|
||||
async postTaskActivity(id: string, message: string, type = 'comment') {
|
||||
const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, type }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.error || `HTTP ${res.status}`)
|
||||
}
|
||||
},
|
||||
|
||||
/* ── API: Fetch agent workflow overview ──────── */
|
||||
async fetchAgentOverview(staleHours = 2) {
|
||||
this.agentOverviewLoading = true
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity } from '@lucide/vue'
|
||||
import { ArrowLeft, Bot, Loader2, AlertCircle, Activity, RefreshCw } from '@lucide/vue'
|
||||
import { apiFetch } from '../services/api'
|
||||
import type { AgentDetail } from '../types'
|
||||
import ConfigTabs from '../components/config/ConfigTabs.vue'
|
||||
import ConfigEditor from '../components/config/ConfigEditor.vue'
|
||||
import { openDashboardLiveStream } from '../services/live'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -18,6 +19,19 @@ const configFiles = ref<ConfigFileInfo[]>([])
|
||||
const activeTab = ref(0)
|
||||
const configsLoading = ref(false)
|
||||
const configsError = ref('')
|
||||
const activityItems = ref<AgentActivityItem[]>([])
|
||||
const activityLoading = ref(false)
|
||||
const activityError = ref('')
|
||||
const summaryLoading = ref(false)
|
||||
const summaryError = ref('')
|
||||
const summary = ref<AgentSummary | null>(null)
|
||||
const liveConnected = ref(false)
|
||||
const liveUnavailable = ref(false)
|
||||
let liveAbort: AbortController | null = null
|
||||
let activityReloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let liveReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let liveStreamStopped = false
|
||||
let lastLiveSequence = 0
|
||||
|
||||
const initLoading = ref(true)
|
||||
|
||||
@@ -28,6 +42,9 @@ interface EditorState {
|
||||
dirty: boolean
|
||||
saveStatus: 'idle' | 'saved' | 'error'
|
||||
saveMessage: string
|
||||
backupStatus: string
|
||||
reloadStatus: string
|
||||
reloadMessage: string
|
||||
}
|
||||
|
||||
interface ConfigFileInfo {
|
||||
@@ -40,6 +57,46 @@ interface ConfigFileDetail extends ConfigFileInfo {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface AgentActivityItem {
|
||||
id: number | null
|
||||
type: string
|
||||
message: string
|
||||
at: string
|
||||
source: string
|
||||
relativeTime?: string | null
|
||||
}
|
||||
|
||||
interface AgentSummary {
|
||||
now: AgentSummaryItem
|
||||
today: AgentSummaryItem
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
interface AgentSummaryItem {
|
||||
text: string
|
||||
source: string
|
||||
timestamp?: string | null
|
||||
}
|
||||
|
||||
interface SaveConfigResult {
|
||||
fileName: string
|
||||
size: number
|
||||
modifiedAt: string
|
||||
validation: {
|
||||
status: string
|
||||
fileKind: string
|
||||
errors: string[]
|
||||
}
|
||||
backup: {
|
||||
status: string
|
||||
backupCreated: boolean
|
||||
}
|
||||
reloadCheck: {
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
const editorState = ref<EditorState>({
|
||||
content: '',
|
||||
savedContent: '',
|
||||
@@ -47,6 +104,9 @@ const editorState = ref<EditorState>({
|
||||
dirty: false,
|
||||
saveStatus: 'idle',
|
||||
saveMessage: '',
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: '',
|
||||
})
|
||||
|
||||
const agentId = route.params.id as string
|
||||
@@ -83,10 +143,10 @@ function formatModifiedAt(dateStr: string): string {
|
||||
|
||||
const statusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'Online': return '#51d49a'
|
||||
case 'Degraded': return '#e5b05e'
|
||||
case 'Offline': return '#e16e75'
|
||||
default: return '#7e8799'
|
||||
case 'Online': return 'var(--st-work)'
|
||||
case 'Degraded': return 'var(--st-queue)'
|
||||
case 'Offline': return 'var(--st-block)'
|
||||
default: return 'var(--tx-3)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +159,22 @@ function formatLastSeen(dateStr?: string): string {
|
||||
})
|
||||
}
|
||||
|
||||
function formatActivityTime(item: AgentActivityItem): string {
|
||||
if (item.relativeTime) return item.relativeTime
|
||||
const d = new Date(item.at)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function activityTypeLabel(type: string): string {
|
||||
if (type === 'thinking') return 'Thinking'
|
||||
if (type === 'handoff') return 'Handoff'
|
||||
if (type === 'task') return 'Task'
|
||||
return 'Activity'
|
||||
}
|
||||
|
||||
async function loadAgent() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
@@ -113,6 +189,110 @@ async function loadAgent() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActivity() {
|
||||
activityLoading.value = true
|
||||
activityError.value = ''
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/activity`)
|
||||
if (!response.ok) throw new Error('Failed to load activity')
|
||||
activityItems.value = await response.json()
|
||||
} catch (e) {
|
||||
activityError.value = e instanceof Error ? e.message : 'Failed to load activity'
|
||||
} finally {
|
||||
activityLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
summaryLoading.value = true
|
||||
summaryError.value = ''
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/summary`)
|
||||
if (!response.ok) throw new Error('Failed to load summary')
|
||||
summary.value = await response.json()
|
||||
} catch (e) {
|
||||
summaryError.value = e instanceof Error ? e.message : 'Failed to load summary'
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleActivityReload() {
|
||||
if (activityReloadTimer) return
|
||||
activityReloadTimer = setTimeout(async () => {
|
||||
activityReloadTimer = null
|
||||
await loadActivity()
|
||||
await loadSummary()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
function formatSummaryTimestamp(value?: string | null): string {
|
||||
if (!value) return 'No timestamp'
|
||||
const d = new Date(value)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function summarySourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'nexus-activity': return 'Nexus activity'
|
||||
case 'gateway-session-history': return 'Gateway history'
|
||||
case 'derived-mixed': return 'Derived from mixed feed'
|
||||
case 'none': return 'No data'
|
||||
default: return source
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStreamReconnect() {
|
||||
if (liveStreamStopped || liveReconnectTimer) return
|
||||
liveReconnectTimer = setTimeout(() => {
|
||||
liveReconnectTimer = null
|
||||
void connectActivityStream()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
async function connectActivityStream() {
|
||||
liveAbort?.abort()
|
||||
liveAbort = new AbortController()
|
||||
|
||||
try {
|
||||
const stream = await openDashboardLiveStream((event, data) => {
|
||||
const cursor = (data as any)?.cursor
|
||||
if (typeof cursor?.sequence === 'number') lastLiveSequence = cursor.sequence
|
||||
|
||||
if (event === 'snapshot') {
|
||||
liveConnected.value = true
|
||||
liveUnavailable.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (event !== 'update') return
|
||||
const envelope = (data as any)?.envelope
|
||||
if (envelope?.type !== 'activity.created') return
|
||||
|
||||
const agentIds = Array.isArray(envelope?.payload?.agentIds)
|
||||
? envelope.payload.agentIds.map((id: unknown) => String(id).toLowerCase())
|
||||
: []
|
||||
|
||||
if (agentIds.includes(agentId.toLowerCase())) {
|
||||
scheduleActivityReload()
|
||||
}
|
||||
}, { signal: liveAbort.signal, afterSequence: lastLiveSequence || null })
|
||||
|
||||
await stream.closed
|
||||
if (!liveStreamStopped) {
|
||||
liveConnected.value = false
|
||||
scheduleStreamReconnect()
|
||||
}
|
||||
} catch {
|
||||
liveConnected.value = false
|
||||
liveUnavailable.value = true
|
||||
if (!liveStreamStopped) scheduleStreamReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfigFiles() {
|
||||
configsLoading.value = true
|
||||
configsError.value = ''
|
||||
@@ -147,6 +327,9 @@ async function loadFileContent(fileName: string) {
|
||||
dirty: false,
|
||||
saveStatus: 'idle',
|
||||
saveMessage: '',
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: '',
|
||||
}
|
||||
} catch (e) {
|
||||
editorState.value = {
|
||||
@@ -156,6 +339,9 @@ async function loadFileContent(fileName: string) {
|
||||
dirty: false,
|
||||
saveStatus: 'error',
|
||||
saveMessage: e instanceof Error ? e.message : `Failed to load ${fileName}`,
|
||||
backupStatus: 'not_applicable',
|
||||
reloadStatus: 'not_supported',
|
||||
reloadMessage: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +366,9 @@ async function saveFile() {
|
||||
editorState.value.saving = true
|
||||
editorState.value.saveStatus = 'idle'
|
||||
editorState.value.saveMessage = ''
|
||||
editorState.value.backupStatus = 'not_applicable'
|
||||
editorState.value.reloadStatus = 'not_supported'
|
||||
editorState.value.reloadMessage = ''
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/v1/agents/${agentId}/config/${encodeURIComponent(fileName)}`, {
|
||||
@@ -190,14 +379,21 @@ async function saveFile() {
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error((err as any).error || 'Failed to save file')
|
||||
const problem = err as { error?: string; errors?: Record<string, string[]> }
|
||||
const detail = problem.error
|
||||
|| Object.values(problem.errors ?? {}).flat().join(' ')
|
||||
|| 'Failed to save file'
|
||||
throw new Error(detail)
|
||||
}
|
||||
|
||||
const result: { fileName: string; size: number; modifiedAt: string } = await response.json()
|
||||
const result: SaveConfigResult = await response.json()
|
||||
editorState.value.savedContent = editorState.value.content
|
||||
editorState.value.dirty = false
|
||||
editorState.value.saveStatus = 'saved'
|
||||
editorState.value.saveMessage = 'Gespeichert'
|
||||
editorState.value.saveMessage = `Gespeichert · Backup ${result.backup.status}`
|
||||
editorState.value.backupStatus = result.backup.status
|
||||
editorState.value.reloadStatus = result.reloadCheck.status
|
||||
editorState.value.reloadMessage = result.reloadCheck.message
|
||||
|
||||
const idx = configFiles.value.findIndex(f => f.fileName === fileName)
|
||||
if (idx >= 0) {
|
||||
@@ -213,19 +409,33 @@ async function saveFile() {
|
||||
} catch (e) {
|
||||
editorState.value.saveStatus = 'error'
|
||||
editorState.value.saveMessage = e instanceof Error ? e.message : 'Failed to save file'
|
||||
editorState.value.backupStatus = 'not_applicable'
|
||||
editorState.value.reloadStatus = 'not_supported'
|
||||
} finally {
|
||||
editorState.value.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
liveStreamStopped = false
|
||||
initLoading.value = true
|
||||
await Promise.allSettled([
|
||||
loadAgent(),
|
||||
loadConfigFiles(),
|
||||
loadActivity(),
|
||||
loadSummary(),
|
||||
])
|
||||
connectActivityStream()
|
||||
initLoading.value = false
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
liveStreamStopped = true
|
||||
liveAbort?.abort()
|
||||
liveAbort = null
|
||||
if (activityReloadTimer) clearTimeout(activityReloadTimer)
|
||||
if (liveReconnectTimer) clearTimeout(liveReconnectTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -269,6 +479,85 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="thinking-section">
|
||||
<header class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">LIVE</span>
|
||||
<h2>Thinking <span :class="['live-dot', { on: liveConnected }]"></span></h2>
|
||||
<p class="section-note">
|
||||
Nexus activity streams live. Gateway session history remains read-only fallback and refreshes when related Nexus events arrive or on manual reload.
|
||||
</p>
|
||||
</div>
|
||||
<button class="icon-button" :disabled="activityLoading || summaryLoading" @click="Promise.allSettled([loadActivity(), loadSummary()])">
|
||||
<RefreshCw :size="14" :class="{ spin: activityLoading }" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div v-if="summaryLoading && !summary" class="status-message compact">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading summaries...
|
||||
</div>
|
||||
|
||||
<div v-else-if="summaryError && !summary" class="status-message compact error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ summaryError }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="summary" class="summary-row">
|
||||
<div class="summary-card">
|
||||
<span>Now</span>
|
||||
<p>{{ summary.now.text }}</p>
|
||||
<small>{{ summarySourceLabel(summary.now.source) }} · {{ formatSummaryTimestamp(summary.now.timestamp) }}</small>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span>Today</span>
|
||||
<p>{{ summary.today.text }}</p>
|
||||
<small>{{ summarySourceLabel(summary.today.source) }} · {{ formatSummaryTimestamp(summary.today.timestamp) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="status-message compact">
|
||||
No summary available.
|
||||
</div>
|
||||
|
||||
<div v-if="summaryError && summary" class="status-message compact error summary-inline-error">
|
||||
<AlertCircle :size="14" />
|
||||
{{ summaryError }}
|
||||
</div>
|
||||
|
||||
<div v-if="liveUnavailable" class="status-message compact">
|
||||
Live stream reconnecting…
|
||||
</div>
|
||||
|
||||
<div v-if="activityLoading && !activityItems.length" class="status-message compact">
|
||||
<Loader2 :size="16" class="spin" />
|
||||
Loading activity...
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityError" class="status-message compact error">
|
||||
<AlertCircle :size="16" />
|
||||
{{ activityError }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="activityItems.length" class="thinking-list">
|
||||
<article
|
||||
v-for="item in activityItems"
|
||||
:key="`${item.source}-${item.id ?? item.at}-${item.message}`"
|
||||
class="thinking-item"
|
||||
>
|
||||
<div class="thinking-meta">
|
||||
<span class="type-pill">{{ activityTypeLabel(item.type) }}</span>
|
||||
<span>{{ formatActivityTime(item) }}</span>
|
||||
</div>
|
||||
<p>{{ item.message }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="status-message compact">
|
||||
No recent activity.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Config section -->
|
||||
<div class="config-section">
|
||||
<div v-if="configsLoading" class="status-message">
|
||||
@@ -297,6 +586,9 @@ onMounted(async () => {
|
||||
:saving="editorState.saving"
|
||||
:save-status="editorState.saveStatus"
|
||||
:save-message="editorState.saveMessage"
|
||||
:backup-status="editorState.backupStatus"
|
||||
:reload-status="editorState.reloadStatus"
|
||||
:reload-message="editorState.reloadMessage"
|
||||
@update-content="onContentChange"
|
||||
@save="saveFile"
|
||||
/>
|
||||
@@ -320,15 +612,15 @@ onMounted(async () => {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.back-link:hover {
|
||||
border-color: #443d7c;
|
||||
color: #d8dbe3;
|
||||
border-color: var(--line-3);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
@@ -337,11 +629,14 @@ onMounted(async () => {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-message.error {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.status-message.compact {
|
||||
padding: 20px;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
@@ -365,15 +660,15 @@ onMounted(async () => {
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(139,124,246,.1);
|
||||
color: #8b7cf6;
|
||||
color: var(--a-mid);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; }
|
||||
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; }
|
||||
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; }
|
||||
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; }
|
||||
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; }
|
||||
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; }
|
||||
.agent-avatar.iris { background: rgba(139,124,246,.15); color: var(--a-mid); }
|
||||
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: var(--a-blue); }
|
||||
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: var(--a-blue); }
|
||||
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: var(--st-queue); }
|
||||
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: var(--a-purple); }
|
||||
.agent-avatar.executor { background: rgba(77,246,212,.15); color: var(--st-think); }
|
||||
|
||||
.agent-header-info {
|
||||
flex: 1;
|
||||
@@ -383,7 +678,7 @@ onMounted(async () => {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent, #7b6ef2);
|
||||
color: var(--a-mid);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@@ -391,7 +686,7 @@ onMounted(async () => {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.agent-status-row {
|
||||
display: flex;
|
||||
@@ -407,18 +702,153 @@ onMounted(async () => {
|
||||
}
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.status-label.muted { color: #6b7385; }
|
||||
.status-label.muted { color: var(--tx-3); }
|
||||
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
||||
.status-sep { color: #3d4152; font-size: 11px; }
|
||||
.status-sep { color: var(--line-2); font-size: 11px; }
|
||||
|
||||
.thinking-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.section-head .eyebrow {
|
||||
display: block;
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--a-mid);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--tx);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.section-note {
|
||||
margin: 6px 0 0;
|
||||
color: var(--tx-3);
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
max-width: 560px;
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--tx-3);
|
||||
}
|
||||
.live-dot.on {
|
||||
background: var(--st-work);
|
||||
}
|
||||
.icon-button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: rgba(255,255,255,.03);
|
||||
color: var(--tx-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-button:disabled {
|
||||
opacity: .65;
|
||||
cursor: default;
|
||||
}
|
||||
.thinking-list {
|
||||
display: grid;
|
||||
}
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: rgba(255,255,255,.05);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.summary-row > div {
|
||||
background: var(--panel);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.summary-card small {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--tx-3);
|
||||
font-size: 9.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.summary-row span {
|
||||
display: block;
|
||||
color: var(--tx-3);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.summary-row p {
|
||||
margin: 0;
|
||||
color: var(--tx-2);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.thinking-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.05);
|
||||
}
|
||||
.thinking-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.thinking-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--tx-3);
|
||||
font-size: 10px;
|
||||
}
|
||||
.type-pill {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(123,110,242,.24);
|
||||
color: var(--a-mid);
|
||||
background: rgba(123,110,242,.08);
|
||||
font-size: 9px;
|
||||
}
|
||||
.thinking-item p {
|
||||
margin: 0;
|
||||
color: var(--tx-2);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.summary-inline-error {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-page {
|
||||
max-width: 100%;
|
||||
}
|
||||
.summary-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Bot, Code2, Server, Shield, Search, Terminal, Users } from '@lucide/vue'
|
||||
import { Bot, Code2, Server, Shield, Search, Terminal, Users, Wifi, WifiOff } from '@lucide/vue'
|
||||
import { apiFetch } from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -12,16 +14,34 @@ interface AgentCard {
|
||||
tags: string[]
|
||||
color: string
|
||||
icon: string
|
||||
model?: string
|
||||
statusLabel?: string
|
||||
statusKind?: 'connected' | 'thinking' | 'blocked' | 'ready' | 'stale' | 'error' | 'unsupported'
|
||||
statusDetail?: string | null
|
||||
isActive?: boolean
|
||||
progress?: number
|
||||
currentTask?: string | null
|
||||
}
|
||||
|
||||
const agents: AgentCard[] = [
|
||||
interface GatewayRuntimeInfo {
|
||||
reachable: boolean
|
||||
version?: string | null
|
||||
requiredVersion?: string | null
|
||||
versionPinned: boolean
|
||||
versionMatches: boolean
|
||||
versionStatus: 'matched' | 'drift' | 'missing' | 'unpinned' | 'unknown' | 'error'
|
||||
message?: string | null
|
||||
warning?: string | null
|
||||
}
|
||||
|
||||
const fallbackAgents: AgentCard[] = [
|
||||
{
|
||||
id: 'iris',
|
||||
name: 'Iris',
|
||||
role: 'Chief of Staff',
|
||||
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
||||
tags: ['Orchestration', 'Delegation', 'Approval'],
|
||||
color: '#8b7cf6',
|
||||
color: '#7c6cff',
|
||||
icon: 'bot',
|
||||
},
|
||||
{
|
||||
@@ -30,7 +50,7 @@ const agents: AgentCard[] = [
|
||||
role: 'Lead Developer',
|
||||
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
||||
tags: ['Coding', 'Development', 'Builds'],
|
||||
color: '#4d8cf6',
|
||||
color: '#4f7cff',
|
||||
icon: 'code',
|
||||
},
|
||||
{
|
||||
@@ -39,7 +59,7 @@ const agents: AgentCard[] = [
|
||||
role: 'Infrastructure Engineer',
|
||||
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
|
||||
tags: ['Infrastructure', 'Deployment', 'Docker'],
|
||||
color: '#4da8f6',
|
||||
color: '#4f7cff',
|
||||
icon: 'server',
|
||||
},
|
||||
{
|
||||
@@ -48,7 +68,7 @@ const agents: AgentCard[] = [
|
||||
role: 'Code QA',
|
||||
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
||||
tags: ['QA', 'Security', 'Code Review'],
|
||||
color: '#f6a84d',
|
||||
color: '#fbbf24',
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
@@ -57,7 +77,7 @@ const agents: AgentCard[] = [
|
||||
role: 'Research Analyst',
|
||||
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
||||
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
||||
color: '#8b4df6',
|
||||
color: '#b557f6',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
@@ -66,11 +86,102 @@ const agents: AgentCard[] = [
|
||||
role: 'Host Executor',
|
||||
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
||||
tags: ['Execution', 'Docker', 'VPS'],
|
||||
color: '#4df6d4',
|
||||
color: '#34d6f5',
|
||||
icon: 'terminal',
|
||||
},
|
||||
]
|
||||
|
||||
const agents = ref<AgentCard[]>([])
|
||||
const gateway = ref<GatewayRuntimeInfo | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const agentCount = computed(() => agents.value.length)
|
||||
const hasAgents = computed(() => agents.value.length > 0)
|
||||
const gatewayWarning = computed(() => gateway.value?.warning || '')
|
||||
const gatewayLabel = computed(() => {
|
||||
if (!gateway.value) return 'Gateway wird geprüft'
|
||||
if (!gateway.value.reachable) return gateway.value.message || 'Gateway offline'
|
||||
switch (gateway.value.versionStatus) {
|
||||
case 'matched':
|
||||
return `Pinned ${gateway.value.requiredVersion}`
|
||||
case 'drift':
|
||||
return 'Version drift'
|
||||
case 'missing':
|
||||
return 'Version fehlt'
|
||||
case 'unknown':
|
||||
return 'Version unbekannt'
|
||||
case 'unpinned':
|
||||
return gateway.value.version ? `Detected ${gateway.value.version}` : 'Unpinned'
|
||||
default:
|
||||
return gateway.value.message || 'Gateway online'
|
||||
}
|
||||
})
|
||||
const gatewayChipClass = computed(() => {
|
||||
if (!gateway.value) return 'neutral'
|
||||
if (!gateway.value.reachable) return 'error'
|
||||
if (gateway.value.warning) return 'warn'
|
||||
return 'ok'
|
||||
})
|
||||
|
||||
async function loadMissionControl() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const [agentsResponse, gatewayResponse] = await Promise.all([
|
||||
apiFetch('/api/dashboard/agents'),
|
||||
apiFetch('/api/dashboard/gateway'),
|
||||
])
|
||||
|
||||
if (agentsResponse.ok) {
|
||||
const data = await agentsResponse.json()
|
||||
agents.value = data.map((item: any) => enrichAgent(item))
|
||||
} else {
|
||||
error.value = await readErrorMessage(agentsResponse, 'Agenten konnten nicht geladen werden')
|
||||
}
|
||||
|
||||
if (gatewayResponse.ok) {
|
||||
gateway.value = await gatewayResponse.json()
|
||||
} else {
|
||||
const gatewayError = await readErrorMessage(gatewayResponse, 'Gateway-Status konnte nicht geladen werden')
|
||||
error.value = error.value ? `${error.value} · ${gatewayError}` : gatewayError
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Mission Control konnte nicht geladen werden'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function enrichAgent(item: any): AgentCard {
|
||||
const fallback = fallbackAgents.find(a => a.id === item.id)
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name || fallback?.name || item.id,
|
||||
role: item.role || fallback?.role || 'Agent',
|
||||
description: item.description || fallback?.description || 'OpenClaw agent',
|
||||
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
|
||||
color: fallback?.color ?? '#6f6aa0',
|
||||
icon: fallback?.icon ?? 'bot',
|
||||
model: item.model,
|
||||
statusLabel: item.statusLabel,
|
||||
statusKind: item.statusKind,
|
||||
statusDetail: item.statusDetail,
|
||||
isActive: item.isActive,
|
||||
progress: item.progress,
|
||||
currentTask: item.currentTask,
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response, fallback: string) {
|
||||
try {
|
||||
const payload = await response.json()
|
||||
return payload?.error || payload?.message || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function goToAgent(id: string) {
|
||||
router.push(`/agents/${id}`)
|
||||
}
|
||||
@@ -86,6 +197,34 @@ function resolveIcon(iconName: string) {
|
||||
default: return Bot
|
||||
}
|
||||
}
|
||||
|
||||
function statusTone(agent: AgentCard) {
|
||||
switch (agent.statusKind) {
|
||||
case 'connected': return 'connected'
|
||||
case 'thinking': return 'thinking'
|
||||
case 'blocked': return 'blocked'
|
||||
case 'stale': return 'stale'
|
||||
case 'error': return 'error'
|
||||
case 'unsupported': return 'unsupported'
|
||||
default: return agent.isActive ? 'connected' : 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
function statusCopy(agent: AgentCard) {
|
||||
if (agent.statusDetail) return agent.statusDetail
|
||||
if (agent.currentTask) return agent.currentTask
|
||||
switch (agent.statusKind) {
|
||||
case 'connected': return 'Session ist erreichbar.'
|
||||
case 'thinking': return 'Agent plant den nächsten Schritt.'
|
||||
case 'blocked': return 'Agent wartet auf Entblockung.'
|
||||
case 'stale': return 'Es gab länger kein neues Signal.'
|
||||
case 'error': return 'Gateway konnte den Session-Status nicht lesen.'
|
||||
case 'unsupported': return 'Session meldet einen nicht unterstützten Zustand.'
|
||||
default: return 'Keine aktive Aufgabe gemeldet.'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMissionControl)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -97,16 +236,28 @@ function resolveIcon(iconName: string) {
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h1>Agents</h1>
|
||||
<p class="header-subtitle">{{ agents.length }} AI agents — each with a real role and a real personality.</p>
|
||||
<p class="header-subtitle">{{ agentCount }} agents · {{ gatewayLabel }}</p>
|
||||
</div>
|
||||
<div class="gateway-chip" :class="gatewayChipClass">
|
||||
<Wifi v-if="gateway?.reachable" :size="13" />
|
||||
<WifiOff v-else :size="13" />
|
||||
{{ gateway?.version || gatewayLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="load-error">Lade Gateway-Status...</div>
|
||||
<div v-else-if="error" class="load-error">{{ error }}</div>
|
||||
<div v-if="gatewayWarning" class="gateway-warning">
|
||||
{{ gatewayWarning }}
|
||||
</div>
|
||||
|
||||
<!-- Agent grid -->
|
||||
<div class="agents-grid">
|
||||
<div v-if="hasAgents" class="agents-grid">
|
||||
<article
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
class="agent-card"
|
||||
:class="`status-${statusTone(agent)}`"
|
||||
:style="{ '--card-color': agent.color }"
|
||||
@click="goToAgent(agent.id)"
|
||||
>
|
||||
@@ -122,6 +273,15 @@ function resolveIcon(iconName: string) {
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-desc">{{ agent.description }}</p>
|
||||
<div class="agent-runtime">
|
||||
<span :class="['runtime-dot', statusTone(agent)]"></span>
|
||||
<span>{{ agent.statusLabel || (agent.isActive ? 'Arbeitet' : 'Bereit') }}</span>
|
||||
<span v-if="agent.model" class="runtime-model">{{ agent.model }}</span>
|
||||
</div>
|
||||
<p class="runtime-detail">{{ statusCopy(agent) }}</p>
|
||||
<div class="progress-track">
|
||||
<span :style="{ width: `${agent.progress ?? 0}%`, background: agent.color }"></span>
|
||||
</div>
|
||||
<div class="card-tags">
|
||||
<span
|
||||
v-for="tag in agent.tags"
|
||||
@@ -139,6 +299,10 @@ function resolveIcon(iconName: string) {
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<h3>Keine Agenten sichtbar</h3>
|
||||
<p>Mission Control hat aktuell keine Agenten aus dem Backend erhalten. Prüfe Gateway-Erreichbarkeit und Agent-Konfiguration.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -163,19 +327,71 @@ function resolveIcon(iconName: string) {
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: rgba(139, 124, 246, 0.1);
|
||||
color: #8b7cf6;
|
||||
color: var(--a-mid);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-text h1 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.header-subtitle {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.gateway-chip {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
color: var(--tx-2);
|
||||
font-size: 10px;
|
||||
}
|
||||
.gateway-chip.ok {
|
||||
color: var(--st-work);
|
||||
border-color: rgba(81, 212, 154, .25);
|
||||
}
|
||||
.gateway-chip.warn {
|
||||
color: var(--st-queue);
|
||||
border-color: rgba(229, 176, 94, .28);
|
||||
}
|
||||
.gateway-chip.error {
|
||||
color: var(--st-block);
|
||||
border-color: rgba(242, 155, 155, .3);
|
||||
}
|
||||
.load-error {
|
||||
margin-bottom: 14px;
|
||||
color: var(--st-queue);
|
||||
font-size: 11px;
|
||||
}
|
||||
.gateway-warning,
|
||||
.empty-state {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(229, 176, 94, .24);
|
||||
background: rgba(229, 176, 94, .08);
|
||||
color: var(--st-queue);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty-state {
|
||||
border-color: var(--line);
|
||||
background: rgba(255,255,255,.03);
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.empty-state h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
color: var(--tx);
|
||||
}
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Agent grid */
|
||||
@@ -201,6 +417,15 @@ function resolveIcon(iconName: string) {
|
||||
box-shadow: 0 0 20px color-mix(in srgb, var(--card-color) 10%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.agent-card.status-error {
|
||||
border-color: rgba(242, 155, 155, .22);
|
||||
}
|
||||
.agent-card.status-unsupported {
|
||||
border-color: rgba(229, 176, 94, .22);
|
||||
}
|
||||
.agent-card.status-stale {
|
||||
border-color: rgba(244, 164, 96, .22);
|
||||
}
|
||||
|
||||
.card-stripe {
|
||||
height: 3px;
|
||||
@@ -226,7 +451,7 @@ function resolveIcon(iconName: string) {
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -239,23 +464,77 @@ function resolveIcon(iconName: string) {
|
||||
margin: 0 0 1px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.card-role {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 10.5px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
flex: 1;
|
||||
}
|
||||
.agent-runtime {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--tx-2);
|
||||
font-size: 9.5px;
|
||||
min-width: 0;
|
||||
}
|
||||
.runtime-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--tx-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.runtime-dot.on {
|
||||
background: var(--st-work);
|
||||
}
|
||||
.runtime-dot.connected { background: var(--st-work); }
|
||||
.runtime-dot.thinking { background: var(--a-blue); }
|
||||
.runtime-dot.blocked { background: var(--st-block); }
|
||||
.runtime-dot.stale { background: var(--st-queue); }
|
||||
.runtime-dot.error { background: var(--st-block); }
|
||||
.runtime-dot.unsupported { background: var(--st-queue); }
|
||||
.runtime-dot.ready { background: var(--tx-3); }
|
||||
.runtime-model {
|
||||
margin-left: auto;
|
||||
max-width: 46%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.runtime-detail {
|
||||
margin: 0 0 10px;
|
||||
min-height: 28px;
|
||||
color: var(--tx-3);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.progress-track {
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.06);
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.progress-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
@@ -282,7 +561,7 @@ function resolveIcon(iconName: string) {
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
transition: color 0.15s;
|
||||
|
||||
@@ -219,7 +219,7 @@ onMounted(() => {
|
||||
}
|
||||
.calendar-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -235,7 +235,7 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.calendar-status {
|
||||
display: flex;
|
||||
@@ -243,11 +243,11 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.calendar-status.error {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
/* Upcoming jobs */
|
||||
@@ -268,25 +268,25 @@ onMounted(() => {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
background: rgba(139,124,246,.1);
|
||||
}
|
||||
.upcoming-item-info strong {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.upcoming-item-schedule {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.upcoming-item-next {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -310,19 +310,19 @@ onMounted(() => {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.job-item-icon.status-completed {
|
||||
color: #27ae60;
|
||||
color: var(--st-work);
|
||||
background: rgba(39, 174, 96, 0.12);
|
||||
}
|
||||
.job-item-icon.status-running {
|
||||
color: #3498db;
|
||||
color: var(--a-blue);
|
||||
background: rgba(52, 152, 219, 0.12);
|
||||
}
|
||||
.job-item-icon.status-failed {
|
||||
color: #e74c3c;
|
||||
color: var(--st-block);
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
}
|
||||
.job-item-icon.status-idle {
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
background: rgba(126, 135, 153, 0.12);
|
||||
}
|
||||
.job-item-info {
|
||||
@@ -332,19 +332,19 @@ onMounted(() => {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.job-item-id {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
margin-bottom: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.job-item-schedule {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.job-item-meta {
|
||||
display: flex;
|
||||
@@ -355,7 +355,7 @@ onMounted(() => {
|
||||
}
|
||||
.job-item-meta small {
|
||||
font-size: 8px;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
text-align: right;
|
||||
}
|
||||
.job-status-badge {
|
||||
@@ -368,19 +368,19 @@ onMounted(() => {
|
||||
}
|
||||
.job-status-badge.status-completed {
|
||||
background: rgba(39, 174, 96, 0.15);
|
||||
color: #27ae60;
|
||||
color: var(--st-work);
|
||||
}
|
||||
.job-status-badge.status-running {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #3498db;
|
||||
color: var(--a-blue);
|
||||
}
|
||||
.job-status-badge.status-failed {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #e74c3c;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.job-status-badge.status-idle {
|
||||
background: rgba(126, 135, 153, 0.15);
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -18,7 +18,6 @@ import { useAgentStore } from '../../stores/agents'
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
import { useDashboardStore } from '../../stores/dashboard'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||
@@ -31,7 +30,6 @@ const agentStore = useAgentStore()
|
||||
const chatStore = useChatStore()
|
||||
const dashboardStore = useDashboardStore()
|
||||
const taskStore = useTaskStore()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
@@ -70,7 +68,6 @@ onMounted(() => {
|
||||
dashboardStore.startPolling()
|
||||
taskStore.startPolling()
|
||||
taskStore.startBoardPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -79,7 +76,6 @@ onUnmounted(() => {
|
||||
dashboardStore.stopPolling()
|
||||
taskStore.stopPolling()
|
||||
taskStore.stopBoardPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -203,14 +203,14 @@ onMounted(loadDocs)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: #6f7889;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.docs-search-bar input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -222,13 +222,13 @@ onMounted(loadDocs)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: #8991a1;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.docs-filter-group select {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -241,7 +241,7 @@ onMounted(loadDocs)
|
||||
}
|
||||
.memory-sidebar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
max-height: 640px;
|
||||
@@ -250,7 +250,7 @@ onMounted(loadDocs)
|
||||
.memory-list-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #7065c8;
|
||||
color: var(--a-mid);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 8px 6px;
|
||||
@@ -264,7 +264,7 @@ onMounted(loadDocs)
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
@@ -280,7 +280,7 @@ onMounted(loadDocs)
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
background: rgba(139,124,246,.1);
|
||||
}
|
||||
.memory-file-info strong {
|
||||
@@ -305,41 +305,41 @@ onMounted(loadDocs)
|
||||
}
|
||||
.doc-category-badge.cat-phases {
|
||||
background: rgba(139,124,246,.12);
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
}
|
||||
.doc-category-badge.cat-skills {
|
||||
background: rgba(81,212,154,.1);
|
||||
color: #51d49a;
|
||||
color: var(--st-work);
|
||||
}
|
||||
.doc-category-badge.cat-workspace {
|
||||
background: rgba(229,176,94,.1);
|
||||
color: #e5b05e;
|
||||
color: var(--st-queue);
|
||||
}
|
||||
.doc-category-badge.cat-nexus {
|
||||
background: rgba(109,159,230,.1);
|
||||
color: #6d9fe6;
|
||||
color: var(--a-blue);
|
||||
}
|
||||
.doc-category-badge.cat-nexus-phases {
|
||||
background: rgba(225,110,117,.1);
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.doc-type-tag {
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid #343947;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 4px;
|
||||
color: #8991a1;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.memory-file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.memory-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
min-height: 480px;
|
||||
@@ -363,7 +363,7 @@ onMounted(loadDocs)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.memory-back-btn {
|
||||
@@ -374,13 +374,13 @@ onMounted(loadDocs)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #8991a1;
|
||||
color: var(--tx-2);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-back-btn:hover {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.memory-status {
|
||||
@@ -389,11 +389,11 @@ onMounted(loadDocs)
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.memory-status.error {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.memory-empty-state {
|
||||
display: flex;
|
||||
@@ -402,27 +402,27 @@ onMounted(loadDocs)
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 360px;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.memory-empty-state h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 14px;
|
||||
color: #a5adba;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.memory-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.memory-rendered {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #d0d4dd;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.memory-rendered :deep(h1),
|
||||
.memory-rendered :deep(h2),
|
||||
.memory-rendered :deep(h3) {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
||||
@@ -438,8 +438,8 @@ onMounted(loadDocs)
|
||||
.memory-rendered :deep(pre) {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--nx-line);
|
||||
background: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.memory-rendered :deep(pre code) {
|
||||
@@ -447,7 +447,7 @@ onMounted(loadDocs)
|
||||
padding: 0;
|
||||
}
|
||||
.memory-rendered :deep(a) {
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
text-decoration: none;
|
||||
}
|
||||
.memory-rendered :deep(a:hover) {
|
||||
@@ -461,11 +461,11 @@ onMounted(loadDocs)
|
||||
}
|
||||
.memory-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--nx-line);
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.memory-rendered :deep(strong) {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -202,7 +202,7 @@ onMounted(loadIncidents)
|
||||
}
|
||||
.incident-sidebar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
max-height: 640px;
|
||||
@@ -211,7 +211,7 @@ onMounted(loadIncidents)
|
||||
.incident-list-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #7065c8;
|
||||
color: var(--a-mid);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 8px 6px;
|
||||
@@ -225,7 +225,7 @@ onMounted(loadIncidents)
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
@@ -243,19 +243,19 @@ onMounted(loadIncidents)
|
||||
border-radius: 6px;
|
||||
}
|
||||
.incident-file-icon.sev-critical {
|
||||
color: #e74c3c;
|
||||
color: var(--st-block);
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-major {
|
||||
color: #e67e22;
|
||||
color: var(--st-queue);
|
||||
background: rgba(230, 126, 34, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-minor {
|
||||
color: #f1c40f;
|
||||
color: var(--st-queue);
|
||||
background: rgba(241, 196, 15, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-unknown {
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
background: rgba(126, 135, 153, 0.12);
|
||||
}
|
||||
.incident-file-info strong {
|
||||
@@ -269,7 +269,7 @@ onMounted(loadIncidents)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.incident-file-excerpt {
|
||||
@@ -278,7 +278,7 @@ onMounted(loadIncidents)
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.severity-badge {
|
||||
@@ -291,23 +291,23 @@ onMounted(loadIncidents)
|
||||
}
|
||||
.severity-badge.sev-critical {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #e74c3c;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.severity-badge.sev-major {
|
||||
background: rgba(230, 126, 34, 0.15);
|
||||
color: #e67e22;
|
||||
color: var(--st-queue);
|
||||
}
|
||||
.severity-badge.sev-minor {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f1c40f;
|
||||
color: var(--st-queue);
|
||||
}
|
||||
.severity-badge.sev-unknown {
|
||||
background: rgba(126, 135, 153, 0.15);
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.incident-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
min-height: 480px;
|
||||
@@ -335,7 +335,7 @@ onMounted(loadIncidents)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.incident-back-btn {
|
||||
display: flex;
|
||||
@@ -345,13 +345,13 @@ onMounted(loadIncidents)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #8991a1;
|
||||
color: var(--tx-2);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.incident-back-btn:hover {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.incident-status {
|
||||
@@ -360,11 +360,11 @@ onMounted(loadIncidents)
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.incident-status.error {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.incident-empty-state {
|
||||
display: flex;
|
||||
@@ -373,27 +373,27 @@ onMounted(loadIncidents)
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 360px;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.incident-empty-state h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 14px;
|
||||
color: #a5adba;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.incident-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.incident-rendered {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #d0d4dd;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.incident-rendered :deep(h1),
|
||||
.incident-rendered :deep(h2),
|
||||
.incident-rendered :deep(h3) {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
.incident-rendered :deep(h1) { font-size: 1.3rem; }
|
||||
@@ -409,8 +409,8 @@ onMounted(loadIncidents)
|
||||
.incident-rendered :deep(pre) {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--nx-line);
|
||||
background: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.incident-rendered :deep(pre code) {
|
||||
@@ -418,7 +418,7 @@ onMounted(loadIncidents)
|
||||
padding: 0;
|
||||
}
|
||||
.incident-rendered :deep(a) {
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
text-decoration: none;
|
||||
}
|
||||
.incident-rendered :deep(a:hover) {
|
||||
@@ -432,11 +432,11 @@ onMounted(loadIncidents)
|
||||
}
|
||||
.incident-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--nx-line);
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.incident-rendered :deep(strong) {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -227,22 +227,22 @@ onMounted(loadMemories)
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
margin-bottom: 16px;
|
||||
color: #6f7889;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.memory-search-bar input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.memory-search-bar kbd {
|
||||
padding: 2px 5px;
|
||||
border: 1px solid #2c313d;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 4px;
|
||||
color: #606979;
|
||||
color: var(--tx-3);
|
||||
font-size: 9px;
|
||||
}
|
||||
.memory-layout {
|
||||
@@ -253,7 +253,7 @@ onMounted(loadMemories)
|
||||
}
|
||||
.memory-sidebar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
max-height: 640px;
|
||||
@@ -262,7 +262,7 @@ onMounted(loadMemories)
|
||||
.memory-list-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #7065c8;
|
||||
color: var(--a-mid);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 8px 6px;
|
||||
@@ -276,7 +276,7 @@ onMounted(loadMemories)
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
@@ -292,7 +292,7 @@ onMounted(loadMemories)
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
background: rgba(139,124,246,.1);
|
||||
}
|
||||
.memory-file-info strong {
|
||||
@@ -307,7 +307,7 @@ onMounted(loadMemories)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.memory-file-excerpt {
|
||||
@@ -318,7 +318,7 @@ onMounted(loadMemories)
|
||||
}
|
||||
.memory-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
min-height: 480px;
|
||||
@@ -342,7 +342,7 @@ onMounted(loadMemories)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.memory-back-btn {
|
||||
display: flex;
|
||||
@@ -352,13 +352,13 @@ onMounted(loadMemories)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #8991a1;
|
||||
color: var(--tx-2);
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-back-btn:hover {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.memory-status {
|
||||
@@ -367,11 +367,11 @@ onMounted(loadMemories)
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 11px;
|
||||
}
|
||||
.memory-status.error {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.memory-empty-state {
|
||||
display: flex;
|
||||
@@ -380,27 +380,27 @@ onMounted(loadMemories)
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 360px;
|
||||
color: #6b7385;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.memory-empty-state h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 14px;
|
||||
color: #a5adba;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.memory-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
.memory-rendered {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #d0d4dd;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
.memory-rendered :deep(h1),
|
||||
.memory-rendered :deep(h2),
|
||||
.memory-rendered :deep(h3) {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
||||
@@ -416,8 +416,8 @@ onMounted(loadMemories)
|
||||
.memory-rendered :deep(pre) {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--nx-line);
|
||||
background: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.memory-rendered :deep(pre code) {
|
||||
@@ -425,7 +425,7 @@ onMounted(loadMemories)
|
||||
padding: 0;
|
||||
}
|
||||
.memory-rendered :deep(a) {
|
||||
color: #a99cf5;
|
||||
color: var(--a-mid);
|
||||
text-decoration: none;
|
||||
}
|
||||
.memory-rendered :deep(a:hover) {
|
||||
@@ -439,11 +439,11 @@ onMounted(loadMemories)
|
||||
}
|
||||
.memory-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--nx-line);
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.memory-rendered :deep(strong) {
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
||||
|
||||
const store = useNotificationStore()
|
||||
const router = useRouter()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
|
||||
const sortedNotifications = computed(() => {
|
||||
return [...store.notifications].sort(
|
||||
@@ -26,10 +24,10 @@ function typeIcon(type: string): string {
|
||||
|
||||
function typeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'task_assigned': return '#4d8cf6'
|
||||
case 'task_review': return '#f6a84d'
|
||||
case 'task_blocked': return '#e16e75'
|
||||
default: return '#7b6ef2'
|
||||
case 'task_assigned': return '#4f7cff'
|
||||
case 'task_review': return '#fbbf24'
|
||||
case 'task_blocked': return '#fb7185'
|
||||
default: return '#7c6cff'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,12 +53,10 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
|
||||
|
||||
onMounted(() => {
|
||||
store.startListPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.stopListPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -137,18 +133,18 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--nx-line, #1f2330);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
color: var(--tx-3);
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
|
||||
.mark-all-btn:hover {
|
||||
background: var(--nx-accent-soft, rgba(123, 110, 242, .08));
|
||||
color: #d8dbe3;
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
/* ── Empty State ── */
|
||||
@@ -158,7 +154,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
color: var(--tx-3);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -186,7 +182,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.notification-card:hover {
|
||||
background: var(--nx-accent-soft, rgba(123, 110, 242, .06));
|
||||
background: rgba(124, 108, 255, 0.06);
|
||||
}
|
||||
|
||||
.notification-card.unread {
|
||||
@@ -215,7 +211,7 @@ onUnmounted(() => {
|
||||
|
||||
.card-title {
|
||||
font-size: 12.5px;
|
||||
color: #d8dbe3;
|
||||
color: var(--tx);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -226,7 +222,7 @@ onUnmounted(() => {
|
||||
|
||||
.card-message {
|
||||
font-size: 10.5px;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
color: var(--tx-3);
|
||||
margin-top: 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
@@ -242,12 +238,12 @@ onUnmounted(() => {
|
||||
|
||||
.timestamp {
|
||||
font-size: 9px;
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
color: var(--tx-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
color: var(--tx-3);
|
||||
opacity: .5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -221,7 +221,7 @@ onMounted(loadProject)
|
||||
}
|
||||
.project-detail-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@@ -234,7 +234,7 @@ onMounted(loadProject)
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
background: linear-gradient(135deg, var(--a-mid), var(--accent-secondary));
|
||||
color: #fff;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
@@ -273,7 +273,7 @@ onMounted(loadProject)
|
||||
}
|
||||
.btn-icon {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
@@ -282,8 +282,8 @@ onMounted(loadProject)
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: var(--nx-accent-soft);
|
||||
color: var(--nx-accent);
|
||||
background: rgba(124, 108, 255, 0.10);
|
||||
color: var(--a-mid);
|
||||
}
|
||||
.btn-icon.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
@@ -294,7 +294,7 @@ onMounted(loadProject)
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
@@ -304,7 +304,7 @@ onMounted(loadProject)
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
@@ -321,7 +321,7 @@ onMounted(loadProject)
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--nx-accent);
|
||||
background: var(--a-mid);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -335,7 +335,7 @@ onMounted(loadProject)
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
@@ -343,7 +343,7 @@ onMounted(loadProject)
|
||||
.progress-section {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid var(--line-2);
|
||||
}
|
||||
.progress-header {
|
||||
display: flex;
|
||||
@@ -361,7 +361,7 @@ onMounted(loadProject)
|
||||
.progress-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--nx-accent), var(--accent-secondary));
|
||||
background: linear-gradient(90deg, var(--a-mid), var(--accent-secondary));
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
@@ -380,7 +380,7 @@ onMounted(loadProject)
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--border);
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.task-list {
|
||||
@@ -394,7 +394,7 @@ onMounted(loadProject)
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.task-icon {
|
||||
@@ -403,7 +403,7 @@ onMounted(loadProject)
|
||||
.task-icon.done { color: rgb(34, 197, 94); }
|
||||
.task-icon.blocked { color: rgb(239, 68, 68); }
|
||||
.task-icon.backlog { color: var(--text-muted); }
|
||||
.task-icon.in-progress { color: var(--nx-accent); }
|
||||
.task-icon.in-progress { color: var(--a-mid); }
|
||||
.task-info {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -441,7 +441,7 @@ onMounted(loadProject)
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user