Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4633e570e8 | |||
| f4bee442db | |||
| 7f1d5b706d | |||
| c3e0e6913b | |||
| b82d88563a | |||
| 7de12c6541 | |||
| b093b0c4b5 | |||
| 2ee1fe973f | |||
| 50d95fa7a9 | |||
| aef76d5f45 | |||
| f564ecfbc7 | |||
| 86ceb2bcce | |||
| 706ff82ccd | |||
| dbda764190 | |||
| 361a64f886 | |||
| a104acf160 | |||
| aaec3eb4ed |
+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=***
|
||||
|
||||
+105
-25
@@ -1,20 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/openclaw/data/openclaw/workspace/nexus}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
|
||||
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
|
||||
COMPOSE_SCRIPT_TEMPLATE="${COMPOSE_SCRIPT:-/tmp/nexus-compose-deploy.sh}"
|
||||
ENV_TMPFILE=""
|
||||
COMPOSE_SCRIPT=""
|
||||
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
|
||||
if [ -n "$COMPOSE_SCRIPT" ]; then
|
||||
rm -f "$COMPOSE_SCRIPT"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
@@ -39,8 +35,7 @@ secure_tmpfile() {
|
||||
}
|
||||
|
||||
ENV_TMPFILE="$(secure_tmpfile "$ENV_TMPFILE_TEMPLATE")"
|
||||
COMPOSE_SCRIPT="$(secure_tmpfile "$COMPOSE_SCRIPT_TEMPLATE")"
|
||||
chmod 600 "$ENV_TMPFILE" "$COMPOSE_SCRIPT"
|
||||
chmod 600 "$ENV_TMPFILE"
|
||||
|
||||
if [ ! -f VERSION ]; then
|
||||
echo "VERSION file not found" >&2
|
||||
@@ -53,6 +48,7 @@ if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
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"
|
||||
|
||||
@@ -64,10 +60,12 @@ 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
|
||||
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"
|
||||
@@ -108,33 +106,79 @@ git archive --format=tar HEAD | docker run --rm -i \
|
||||
chown -R "$dest_owner" /dest
|
||||
'
|
||||
|
||||
echo "Building and starting Docker compose stack"
|
||||
cat > "$COMPOSE_SCRIPT" <<'EOF_DEPLOY'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cat > /tmp/nexus-deploy-env
|
||||
docker compose --env-file /tmp/nexus-deploy-env build
|
||||
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate --remove-orphans --wait
|
||||
docker compose --env-file /tmp/nexus-deploy-env ps
|
||||
rm -f /tmp/nexus-deploy-env
|
||||
EOF_DEPLOY
|
||||
# ── 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 \
|
||||
-v "$COMPOSE_SCRIPT:/deploy.sh:ro" \
|
||||
-w /workspace/nexus \
|
||||
-i \
|
||||
docker:cli \
|
||||
sh /deploy.sh < "$ENV_TMPFILE"
|
||||
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))
|
||||
if curl -fsS --max-time 10 "$BASE_URL/health" >/dev/null; then
|
||||
echo "Health check passed"
|
||||
break
|
||||
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
|
||||
@@ -176,9 +220,45 @@ 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
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
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
|
||||
@@ -106,10 +109,10 @@ jobs:
|
||||
group: deploy-production
|
||||
cancel-in-progress: false
|
||||
if: |
|
||||
github.event_name == 'push' &&
|
||||
github.ref == 'refs/heads/main'
|
||||
gitea.event_name == 'push' &&
|
||||
gitea.ref == 'refs/heads/main'
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||
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 }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Deploy Nexus
|
||||
runs-on: linux
|
||||
env:
|
||||
DEPLOY_PATH: /home/projekte_bao/openclaw/data/openclaw/workspace/nexus
|
||||
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 }}
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
name: Rollback Nexus
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -199,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
|
||||
{
|
||||
@@ -217,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
|
||||
{
|
||||
@@ -238,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
|
||||
{
|
||||
@@ -256,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
|
||||
{
|
||||
@@ -277,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
|
||||
{
|
||||
@@ -372,7 +372,7 @@ public sealed class TaskWorkflowTests
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -235,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;
|
||||
|
||||
@@ -192,7 +192,7 @@ onMounted(loadStatus)
|
||||
}
|
||||
.security-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--r);
|
||||
background: var(--panel);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
@@ -204,27 +204,27 @@ onMounted(loadStatus)
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
color: #a99cff;
|
||||
border-radius: var(--r);
|
||||
color: var(--a-mid);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.security-card-icon.twofa-icon {
|
||||
color: #e5b05e;
|
||||
color: var(--st-queue);
|
||||
background: rgba(229,176,94,.1);
|
||||
}
|
||||
.security-card-icon.passkey-icon {
|
||||
color: #6d9fe6;
|
||||
color: var(--a-blue);
|
||||
background: rgba(109,159,230,.1);
|
||||
}
|
||||
.security-card h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.security-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
}
|
||||
.policy-text {
|
||||
font-size: 11px;
|
||||
@@ -234,7 +234,7 @@ onMounted(loadStatus)
|
||||
.security-desc {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.security-detail-list {
|
||||
@@ -250,14 +250,14 @@ onMounted(loadStatus)
|
||||
}
|
||||
.security-detail-label {
|
||||
font-size: 10px;
|
||||
color: #8991a1;
|
||||
color: var(--tx-2);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.security-detail-value {
|
||||
font-size: 10px;
|
||||
color: #e8eaf0;
|
||||
color: var(--tx);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
@@ -281,11 +281,11 @@ code.security-detail-value {
|
||||
}
|
||||
.status-bool.enabled {
|
||||
background: rgba(81,212,154,.1);
|
||||
color: #51d49a;
|
||||
color: var(--st-work);
|
||||
}
|
||||
.status-bool.disabled {
|
||||
background: rgba(225,110,117,.08);
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.security-status-row {
|
||||
display: flex;
|
||||
@@ -293,16 +293,16 @@ code.security-detail-value {
|
||||
gap: 6px;
|
||||
}
|
||||
.check-icon {
|
||||
color: #51d49a;
|
||||
color: var(--st-work);
|
||||
}
|
||||
.x-icon {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.enabled-text {
|
||||
color: #51d49a;
|
||||
color: var(--st-work);
|
||||
}
|
||||
.disabled-text {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
.memory-status {
|
||||
@@ -311,11 +311,11 @@ code.security-detail-value {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px;
|
||||
color: #7e8799;
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.memory-status.error {
|
||||
color: #e16e75;
|
||||
color: var(--st-block);
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -409,7 +409,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.grad-text {
|
||||
background: linear-gradient(135deg, #4f7cff, #b557f6);
|
||||
background: linear-gradient(135deg, var(--a-blue), var(--a-purple));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -418,7 +418,7 @@ onMounted(() => {
|
||||
.settings-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #6f6aa0;
|
||||
color: var(--tx-3);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
@@ -444,7 +444,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #ece9ff;
|
||||
color: var(--tx);
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid rgba(150, 140, 255, 0.08);
|
||||
}
|
||||
@@ -470,7 +470,7 @@ onMounted(() => {
|
||||
.field label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #a8a3d6;
|
||||
color: var(--tx-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -490,7 +490,7 @@ onMounted(() => {
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 11px;
|
||||
background: rgba(10, 9, 24, 0.55);
|
||||
color: #ece9ff;
|
||||
color: var(--tx);
|
||||
font-size: 13.5px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
outline: none;
|
||||
@@ -513,13 +513,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.galaxy-select option {
|
||||
background: #141130;
|
||||
color: #ece9ff;
|
||||
background: var(--space-3);
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.value-tag {
|
||||
font-size: 14px;
|
||||
color: #ece9ff;
|
||||
color: var(--tx);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -538,7 +538,7 @@ onMounted(() => {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: linear-gradient(135deg, rgba(79, 124, 255, 0.15), rgba(181, 87, 246, 0.15));
|
||||
color: #b8adff;
|
||||
color: var(--a-mid);
|
||||
border: 1px solid rgba(124, 108, 255, 0.15);
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -589,14 +589,14 @@ onMounted(() => {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #6f6aa0;
|
||||
color: var(--tx-3);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.pw-toggle:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: #a8a3d6;
|
||||
color: var(--tx-2);
|
||||
}
|
||||
|
||||
/* ── Messages ──────────────────────────────────── */
|
||||
@@ -613,13 +613,13 @@ onMounted(() => {
|
||||
.msg.error {
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
color: #fda4af;
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: #86efac;
|
||||
color: var(--st-work);
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────── */
|
||||
@@ -630,7 +630,7 @@ onMounted(() => {
|
||||
padding: 9px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
|
||||
background: linear-gradient(135deg, var(--a-blue), var(--a-mid), var(--a-purple));
|
||||
color: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
@@ -665,7 +665,7 @@ onMounted(() => {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
color: #a8a3d6;
|
||||
color: var(--tx-2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
@@ -673,7 +673,7 @@ onMounted(() => {
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: #ece9ff;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
/* ── Admin User Management ───────────────────── */
|
||||
@@ -711,7 +711,7 @@ onMounted(() => {
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(181, 87, 246, 0.2));
|
||||
border: 1px solid rgba(124, 108, 255, 0.15);
|
||||
color: #b8adff;
|
||||
color: var(--a-mid);
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
@@ -725,12 +725,12 @@ onMounted(() => {
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #ece9ff;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 11px;
|
||||
color: #6f6aa0;
|
||||
color: var(--tx-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -754,31 +754,31 @@ onMounted(() => {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(124, 108, 255, 0.1);
|
||||
color: #b8adff;
|
||||
color: var(--a-mid);
|
||||
border: 1px solid rgba(124, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.role-tag.owner {
|
||||
background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(251, 191, 36, 0.15));
|
||||
color: #fde68a;
|
||||
color: var(--st-queue);
|
||||
border-color: rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.role-tag.admin {
|
||||
background: rgba(79, 124, 255, 0.12);
|
||||
color: #93c5fd;
|
||||
color: var(--a-blue);
|
||||
border-color: rgba(79, 124, 255, 0.2);
|
||||
}
|
||||
|
||||
.role-tag.viewer {
|
||||
background: rgba(107, 103, 150, 0.15);
|
||||
color: #a8a3d6;
|
||||
color: var(--tx-2);
|
||||
border-color: rgba(107, 103, 150, 0.2);
|
||||
}
|
||||
|
||||
.user-date {
|
||||
font-size: 10px;
|
||||
color: #6f6aa0;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
|
||||
.loading-pulse {
|
||||
@@ -806,7 +806,7 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px;
|
||||
color: #6f6aa0;
|
||||
color: var(--tx-3);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
@@ -849,7 +849,7 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ece9ff;
|
||||
color: var(--tx);
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
@@ -861,7 +861,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6f6aa0;
|
||||
color: var(--tx-3);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
@@ -870,11 +870,11 @@ onMounted(() => {
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: #ece9ff;
|
||||
color: var(--tx);
|
||||
}
|
||||
|
||||
.req {
|
||||
color: #fb7185;
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user