Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c496608c86 | |||
| c040696d91 | |||
| 7ba0bd26fa | |||
| 4b1d140b53 | |||
| e0c88238da | |||
| b0e65e3980 | |||
| 648a5d2151 | |||
| 1a024eef96 | |||
| 6280e87078 | |||
| 64459ccdb3 | |||
| 38dc2efc6c | |||
| 390bffa208 | |||
| e034883abd | |||
| 6d4e8e7927 | |||
| 0f8939306d | |||
| 58675f0c69 | |||
| 88cafc7b8e | |||
| 485357c6dc | |||
| 36b32f0e88 | |||
| 8a556c25a0 | |||
| f271602f31 | |||
| 63319e1046 | |||
| b730fa1518 | |||
| fadb5d75c4 | |||
| 45a39d319f | |||
| 5ea7aa9611 | |||
| a6fabb90b0 | |||
| db62354c97 | |||
| 20dedcd6fa | |||
| 4ad0f9e493 | |||
| ac4e1cd3cf | |||
| 01c9bda339 | |||
| 1b11793dad | |||
| 98f98b55d5 |
@@ -0,0 +1,156 @@
|
|||||||
|
name: Database Backup
|
||||||
|
run-name: 💾 DB Backup triggered by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
# Owner: DevOps (Architekt)
|
||||||
|
# Trigger: Manual (workflow_dispatch) + optional schedule.
|
||||||
|
#
|
||||||
|
# Strategy:
|
||||||
|
# 1. Connects to the live PostgreSQL container via docker exec.
|
||||||
|
# 2. Runs pg_dumpall (full cluster dump, single file).
|
||||||
|
# 3. Compresses with gzip.
|
||||||
|
# 4. Uploads as a Gitea Action artifact (or writes to host path).
|
||||||
|
# 5. Artifacts are retained per Gitea repo settings (default 90 days).
|
||||||
|
#
|
||||||
|
# Rotation: Gitea artifact expiration handles old backups automatically.
|
||||||
|
# For longer retention, configure an external cron job or use the
|
||||||
|
# host_path output to copy the backup elsewhere.
|
||||||
|
#
|
||||||
|
# Restoration: See phases/deployment.md for step-by-step instructions.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
concurrency:
|
||||||
|
group: db-backup
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
keep_on_host:
|
||||||
|
description: 'Also copy backup to host path?'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
host_backup_path:
|
||||||
|
description: 'Host path for backup (only if keep_on_host is true)'
|
||||||
|
required: false
|
||||||
|
default: '/opt/openclaw/backups'
|
||||||
|
type: string
|
||||||
|
|
||||||
|
# Optional: uncomment to enable nightly automatic backups
|
||||||
|
# schedule:
|
||||||
|
# - cron: '0 3 * * *' # Every night at 03:00 UTC
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
backup:
|
||||||
|
name: Backup PostgreSQL
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
ENV_TMPFILE: /tmp/nexus-backup-env
|
||||||
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
|
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||||
|
BACKUP_CONTAINER_NAME: nexus-postgres-1
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 1: Generate backup filename
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Generate backup identifier
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
TIMESTAMP=$(date -u +'%Y-%m-%dT%H%M%SZ')
|
||||||
|
echo "timestamp=${TIMESTAMP}" >> "$GITEA_OUTPUT"
|
||||||
|
echo "filename=nexus-backup-${TIMESTAMP}.sql.gz" >> "$GITEA_OUTPUT"
|
||||||
|
echo "📅 Backup ID: ${TIMESTAMP}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 2: Dump PostgreSQL via docker exec
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Dump database
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "🗄️ Dumping PostgreSQL cluster..."
|
||||||
|
|
||||||
|
docker exec "${BACKUP_CONTAINER_NAME}" \
|
||||||
|
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
|
||||||
|
| gzip > "${{ steps.meta.outputs.filename }}"
|
||||||
|
|
||||||
|
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
|
||||||
|
echo "✅ Backup written: ${{ steps.meta.outputs.filename }} (${SIZE})"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 3: Upload backup as Gitea artifact
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Upload backup artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: nexus-backup-${{ steps.meta.outputs.timestamp }}
|
||||||
|
path: ${{ steps.meta.outputs.filename }}
|
||||||
|
retention-days: 90
|
||||||
|
compression-level: 0 # already gzipped
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 4: Optional — copy to host filesystem
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Copy backup to host (optional)
|
||||||
|
if: inputs.keep_on_host == true
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST_PATH="${{ inputs.host_backup_path }}"
|
||||||
|
|
||||||
|
# Create host dir if it doesn't exist
|
||||||
|
docker run --rm \
|
||||||
|
-v "${HOST_PATH}:/backup-target" \
|
||||||
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -c "
|
||||||
|
mkdir -p /backup-target && \
|
||||||
|
cp /src/${{ steps.meta.outputs.filename }} /backup-target/ && \
|
||||||
|
echo '✅ Backup copied to host: ${HOST_PATH}/${{ steps.meta.outputs.filename }}'
|
||||||
|
"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Verify backup integrity
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Verify backup integrity
|
||||||
|
run: |
|
||||||
|
echo "🔍 Verifying backup integrity..."
|
||||||
|
if gzip -t "${{ steps.meta.outputs.filename }}"; then
|
||||||
|
echo "✅ Backup gzip integrity check passed"
|
||||||
|
else
|
||||||
|
echo "❌ Backup file is corrupted!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Quick content check: should start with PostgreSQL dump header
|
||||||
|
HEADER=$(zcat "${{ steps.meta.outputs.filename }}" | head -1)
|
||||||
|
if echo "$HEADER" | grep -qE '^(-- PostgreSQL database cluster dump|-- Dumped|--)'; then
|
||||||
|
echo "✅ Backup content header check passed"
|
||||||
|
else
|
||||||
|
echo "⚠️ Unexpected backup header (may still be valid): $HEADER"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Backup Summary
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Backup Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
STATUS="${{ job.status }}"
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " 💾 Database Backup Summary"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " File: ${{ steps.meta.outputs.filename }}"
|
||||||
|
echo " Timestamp: ${{ steps.meta.outputs.timestamp }}"
|
||||||
|
echo " Triggered: @${{ gitea.actor }}"
|
||||||
|
echo " On host: ${{ inputs.keep_on_host == 'true' && inputs.host_backup_path || 'No (artifact only)' }}"
|
||||||
|
echo " Status: ${STATUS}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
if [ "${STATUS}" = "success" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "💡 Restore command (manual, on host):"
|
||||||
|
echo " zcat ${{ steps.meta.outputs.filename }} | docker exec -i nexus-postgres-1 psql -U nexus -d postgres"
|
||||||
|
fi
|
||||||
@@ -27,14 +27,13 @@ jobs:
|
|||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore backend/Nexus.Api.csproj
|
run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release
|
run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
# ─── Frontend ──────────────────────────────────
|
# ─── Frontend ──────────────────────────────────
|
||||||
frontend:
|
frontend:
|
||||||
@@ -54,16 +53,18 @@ jobs:
|
|||||||
corepack enable
|
corepack enable
|
||||||
corepack prepare pnpm@latest --activate
|
corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
# --prefer-offline: use cached packages if available in the runner image
|
|
||||||
# Lockfile IS committed — regenerated on changes via pnpm install.
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --no-frozen-lockfile --prefer-offline
|
run: pnpm install --frozen-lockfile
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: pnpm exec vue-tsc --noEmit
|
run: pnpm exec vue-tsc --noEmit
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
@@ -79,8 +80,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Check for .env leaks
|
- name: Check for .env leaks
|
||||||
run: |
|
run: |
|
||||||
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then
|
echo "🔍 Scanning for potential secrets in source code..."
|
||||||
echo "⚠️ Warning: Potential secrets in source code (review manually)"
|
HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
|
||||||
|
echo "$HITS"
|
||||||
|
echo ""
|
||||||
|
echo "Remove these values and use environment variables or a secrets manager instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
|
||||||
|
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
|
||||||
|
if [ -n "$LOOSE" ]; then
|
||||||
|
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
|
||||||
|
echo "$LOOSE"
|
||||||
else
|
else
|
||||||
echo "✅ No obvious secrets found"
|
echo "✅ No obvious secrets found"
|
||||||
fi
|
fi
|
||||||
|
|||||||
+212
-112
@@ -1,169 +1,243 @@
|
|||||||
name: Deploy to Production
|
name: Deploy to Production
|
||||||
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }}
|
run-name: 🚀 Deploy by @${{ gitea.actor }}
|
||||||
|
|
||||||
# ── Concurrency: one deploy at a time, cancel queued ones ──
|
# ───────────────────────────────────────────────────────
|
||||||
# Why: prevents race conditions when CI triggers deploy while
|
# Owner: DevOps (Architekt)
|
||||||
# a manual deploy is still running. The latest deploy wins.
|
# CD v3 — 2026-06-13
|
||||||
|
#
|
||||||
|
# Triggers:
|
||||||
|
# 1. AUTOMATIC after successful CI on main (workflow_run)
|
||||||
|
# → Uses safe defaults: patch bump, all services, main ref.
|
||||||
|
# → Commits marked with [skip ci] are filtered at job level
|
||||||
|
# (prevents version-bump loops).
|
||||||
|
# 2. MANUAL via workflow_dispatch with full parameter control.
|
||||||
|
#
|
||||||
|
# Concurrency: one deploy at a time.
|
||||||
|
# Queued deploys wait — no race conditions with parallel builds.
|
||||||
|
#
|
||||||
|
# Version Management:
|
||||||
|
# The VERSION file in the repo root is the single source of truth.
|
||||||
|
# Version bumps happen in the Dev workflow BEFORE merge to main.
|
||||||
|
# The deploy workflow only reads, validates, and logs the version.
|
||||||
|
# The [skip ci] filter remains as a safety layer for auto-triggers.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────
|
|
||||||
# Trigger: automatic after CI success, or manual dispatch.
|
|
||||||
# Runner: uses ubuntu-latest label (consistently present on
|
|
||||||
# runner id=5: linux,dotnet,node,deploy,ubuntu-latest,…).
|
|
||||||
# Standard labels avoid custom-label matching edge cases.
|
|
||||||
# ───────────────────────────────────────────────────
|
|
||||||
on:
|
on:
|
||||||
|
# ── Auto-Trigger: after successful CI on main ──
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI - Build & Test"]
|
workflows: ["CI - Build & Test"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
# ── Manual Trigger (full control) ──
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
bump_version:
|
|
||||||
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)'
|
|
||||||
required: false
|
|
||||||
default: 'patch'
|
|
||||||
type: string
|
|
||||||
options:
|
|
||||||
- 'patch'
|
|
||||||
- 'minor'
|
|
||||||
- 'major'
|
|
||||||
service:
|
service:
|
||||||
description: 'Service to deploy (empty = all)'
|
description: 'Service to deploy (empty = all)'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
type: string
|
type: string
|
||||||
no_cache:
|
no_cache:
|
||||||
description: 'Disable build cache'
|
description: 'Disable Docker build cache'
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
|
git_ref:
|
||||||
|
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
|
||||||
|
required: false
|
||||||
|
default: 'main'
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Nexus
|
name: Deploy Nexus
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }}
|
if: |
|
||||||
|
(github.event_name == 'workflow_dispatch') ||
|
||||||
|
(github.event_name == 'workflow_run' &&
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
|
||||||
|
|
||||||
|
# ── Env for the deploy target path ──
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||||
|
ENV_TMPFILE: /tmp/nexus-deploy-env
|
||||||
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
|
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
||||||
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# ── Step 1: Checkout ─────────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Checkout latest code
|
# Step 1: Checkout
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
# ── Step 2: Version bump (race-free) ─────
|
# ═══════════════════════════════════════════════════
|
||||||
# Derives current version from git tags (not VERSION file) to
|
# Step 2: Set up Git identity
|
||||||
# avoid race conditions where tag exists but VERSION is stale.
|
# ═══════════════════════════════════════════════════
|
||||||
# Uses --force on tag+push to handle retries after failed runs.
|
- name: Configure Git
|
||||||
- name: Version Bump
|
run: |
|
||||||
|
git config user.email "devops@noveria.net"
|
||||||
|
git config user.name "DevOps"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 3: Resolve deploy version
|
||||||
|
#
|
||||||
|
# Reads VERSION from repo root — the single source of truth.
|
||||||
|
# Validates semver format, logs version + git metadata.
|
||||||
|
# No git mutation: version bumps happen in the Dev workflow.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Resolve Version
|
||||||
|
id: version
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Source of truth: latest git tag
|
# 1. Check VERSION exists
|
||||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
if [ ! -f VERSION ]; then
|
||||||
CURRENT_VERSION="${TAG#v}"
|
echo "❌ VERSION file not found"
|
||||||
echo "📦 Current version (from git tags): $CURRENT_VERSION"
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
MAJOR=$(echo "$CURRENT_VERSION" | cut -d. -f1)
|
# 2. Read and validate semver format
|
||||||
MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2)
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3)
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "❌ Invalid semver in VERSION: '$VERSION'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
case "${{ inputs.bump_version }}" in
|
# 3. Log version, git ref, and describe
|
||||||
major)
|
GIT_REF=$(git rev-parse --short HEAD)
|
||||||
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
GIT_DESCRIBE=$(git describe --always --dirty)
|
||||||
minor)
|
|
||||||
MINOR=$((MINOR + 1)); PATCH=0 ;;
|
|
||||||
patch|*)
|
|
||||||
PATCH=$((PATCH + 1)) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
echo "📦 Deploy version: v${VERSION}"
|
||||||
echo "🏷️ New version: $NEW_VERSION"
|
echo "🔖 Git ref: ${GIT_REF}"
|
||||||
echo "$NEW_VERSION" > VERSION
|
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
|
||||||
|
|
||||||
git config user.email "devops@noveria.net"
|
# 4. Set outputs for downstream steps
|
||||||
git config user.name "DevOps"
|
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
|
||||||
git add VERSION
|
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
||||||
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]"
|
|
||||||
|
|
||||||
# --force avoids "tag already exists" when re-running after a failed attempt
|
# ═══════════════════════════════════════════════════
|
||||||
git tag -f "v${NEW_VERSION}"
|
# Step 4: Build .env from secrets (SAFE)
|
||||||
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --force --tags
|
#
|
||||||
echo "✅ Version bumped to v${NEW_VERSION}"
|
# Secrets are written to /tmp/nexus-deploy-env — NEVER
|
||||||
|
# to a file inside the workspace that gets rsync'd to
|
||||||
# ── Step 3: Sync code + .env to host ──────
|
# the host. The temp file is deleted immediately after
|
||||||
# Creates .env from Gitea secrets in the workspace, then syncs
|
# compose operations complete.
|
||||||
# everything (except .git) to the host deploy path via DIND.
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Sync code + .env to host
|
- name: Prepare .env (secrets → temp file)
|
||||||
run: |
|
run: |
|
||||||
# Create .env from Gitea secrets in the workspace
|
set -euo pipefail
|
||||||
cat > "${{ gitea.workspace }}/.env" << 'ENVEOF'
|
|
||||||
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
# Nexus Production Environment — auto-generated by CD pipeline
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
# Managed via Gitea secrets → do not edit manually on the host
|
# Managed via Gitea Secrets → do NOT edit manually on the host.
|
||||||
|
# This file lives in /tmp and is removed after deploy completes.
|
||||||
POSTGRES_DB=nexus
|
POSTGRES_DB=nexus
|
||||||
POSTGRES_USER=nexus
|
POSTGRES_USER=nexus
|
||||||
POSTGRES_PASSWORD=${{ secrets.ENV_POSTGRES_PASSWORD }}
|
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||||
JWT_KEY=${{ secrets.ENV_JWT_KEY }}
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
OWNER_EMAIL=vmbao62@hotmail.de
|
OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OWNER_PASSWORD=${{ secrets.ENV_OWNER_PASSWORD }}
|
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
|
||||||
OWNER_DISPLAY_NAME=
|
OWNER_DISPLAY_NAME=
|
||||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${{ secrets.ENV_OPENCLAW_TOKEN }}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
ENVEOF
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
|
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Sync code to host (without .env in workspace)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Sync code to host
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
# Sync everything (except .git) from workspace to host
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${{ gitea.workspace }}:/src:ro" \
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
alpine:latest \
|
alpine:latest \
|
||||||
sh -c "
|
sh -c "
|
||||||
cd /src && \
|
cd /src && \
|
||||||
find . -mindepth 1 -maxdepth 1 \
|
find . -mindepth 1 -maxdepth 1 \
|
||||||
! -name .git \
|
! -name .git \
|
||||||
-exec cp -a {} /dest/ \;
|
-exec cp -r {} /dest/ \; && \
|
||||||
|
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||||
|
chown -R \"\$DEST_OWNER\" /dest
|
||||||
"
|
"
|
||||||
echo "✅ Code + .env synced to host deploy path"
|
|
||||||
|
|
||||||
# ── Step 4: Docker Buildx ─────────────────
|
echo "✅ Code synced to ${DEPLOY_PATH}"
|
||||||
- name: Set up Docker Buildx
|
|
||||||
run: docker buildx create --use 2>/dev/null || true
|
|
||||||
|
|
||||||
# ── Step 5: Build & Deploy ────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Build & Deploy
|
||||||
|
#
|
||||||
|
# The temp .env file is bind-mounted read-only into the
|
||||||
|
# docker:cli container so compose can resolve variables.
|
||||||
|
# It is NEVER written into the workspace directory.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Build & Deploy
|
- name: Build & Deploy
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Auto-deploy: always use cache. Manual: respect no_cache input.
|
||||||
|
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
|
||||||
BUILD_ARGS=""
|
BUILD_ARGS=""
|
||||||
if [ "${{ inputs.no_cache }}" = "true" ]; then
|
if [ "$NO_CACHE" = "true" ]; then
|
||||||
BUILD_ARGS="--no-cache"
|
BUILD_ARGS="--no-cache"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
|
-i \
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh -c "
|
sh -c "
|
||||||
set -e
|
set -e
|
||||||
if [ -n '${{ inputs.service }}' ]; then
|
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||||
echo '🚀 Deploying service: ${{ inputs.service }}'
|
cat > /tmp/nexus-deploy-env
|
||||||
docker compose build ${BUILD_ARGS} ${{ inputs.service }}
|
if [ -n '${SERVICE_ARG}' ]; then
|
||||||
docker compose up -d --wait --force-recreate ${{ inputs.service }}
|
echo '🚀 Deploying service: ${SERVICE_ARG}'
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
|
||||||
|
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate ${SERVICE_ARG}
|
||||||
else
|
else
|
||||||
echo '🚀 Deploying all services'
|
echo '🚀 Deploying all services'
|
||||||
docker compose build ${BUILD_ARGS}
|
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
|
||||||
docker compose up -d --wait --force-recreate
|
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
||||||
fi
|
fi
|
||||||
"
|
" < "${ENV_TMPFILE}"
|
||||||
|
|
||||||
# ── Step 6: Health Check (backoff) ────────
|
echo "✅ Docker compose up completed"
|
||||||
# Exponential-ish backoff: 1s, 2s, 3s, 5s, 8s, 13s (~32s total).
|
|
||||||
# Why: cold-start containers need variable warmup time;
|
# ═══════════════════════════════════════════════════
|
||||||
# fixed 5s intervals either wait too long or give up too early.
|
# Step 7: Clean up temp .env
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Clean up temp .env
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "${ENV_TMPFILE}" ]; then
|
||||||
|
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
||||||
|
echo "🧹 Temp .env removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 8: Health Check (exponential backoff)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Health Check
|
- name: Health Check
|
||||||
run: |
|
run: |
|
||||||
echo "🏥 Health check..."
|
echo "🏥 Health check..."
|
||||||
@@ -186,11 +260,10 @@ jobs:
|
|||||||
echo "❌ Health check failed after $MAX attempts"
|
echo "❌ Health check failed after $MAX attempts"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# ── Step 7: Smoke test (multi-endpoint) ───
|
# ═══════════════════════════════════════════════════
|
||||||
# Tests multiple endpoints to catch partial failures.
|
# Step 9: Smoke Test
|
||||||
# Why: a single /dashboard check can miss backend-only outages;
|
# ═══════════════════════════════════════════════════
|
||||||
# /health tests the API + database + runtime status.
|
- name: Smoke Test
|
||||||
- name: Verify (smoke test)
|
|
||||||
run: |
|
run: |
|
||||||
echo "🔍 Smoke test..."
|
echo "🔍 Smoke test..."
|
||||||
PASS=0
|
PASS=0
|
||||||
@@ -199,7 +272,8 @@ jobs:
|
|||||||
|
|
||||||
check() {
|
check() {
|
||||||
local path="$1" label="$2" expected="${3:-200}"
|
local path="$1" label="$2" expected="${3:-200}"
|
||||||
local code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||||
printf " %-25s HTTP %s" "${label}:" "${code}"
|
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||||
if [ "$code" = "$expected" ]; then
|
if [ "$code" = "$expected" ]; then
|
||||||
echo " ✅"
|
echo " ✅"
|
||||||
@@ -212,6 +286,7 @@ jobs:
|
|||||||
|
|
||||||
check "/dashboard" "Dashboard" 200
|
check "/dashboard" "Dashboard" 200
|
||||||
check "/health" "Health API" 200
|
check "/health" "Health API" 200
|
||||||
|
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Results: $PASS passed, $FAIL failed"
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
@@ -219,25 +294,50 @@ jobs:
|
|||||||
echo "❌ Smoke test failed!"
|
echo "❌ Smoke test failed!"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "✅ Deployment verified"
|
echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
|
||||||
|
|
||||||
# ── Step 8: Rollback hint ────────────────
|
# ═══════════════════════════════════════════════════
|
||||||
# On any failure, prints the previous deploy tag for quick manual rollback.
|
# Step 10: Deployment Summary
|
||||||
# Why: reduces MTTR (mean time to recovery) by providing the exact
|
# ═══════════════════════════════════════════════════
|
||||||
# git tag to roll back to without needing to look it up manually.
|
- name: Deployment Summary
|
||||||
- name: Rollback hint
|
if: always()
|
||||||
|
run: |
|
||||||
|
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " 📦 Deploy Summary"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " Version: v${{ steps.version.outputs.version }}"
|
||||||
|
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
||||||
|
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
|
||||||
|
echo " Trigger: ${TRIGGER}"
|
||||||
|
echo " Actor: @${{ gitea.actor }}"
|
||||||
|
echo " Status: ${{ job.status }}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 11: Failure → Reviewer Handoff
|
||||||
|
#
|
||||||
|
# On failure: DevOps (Architekt) analyses the log,
|
||||||
|
# notifies Reviewer (Code-Fixer) with the exact error.
|
||||||
|
# This output provides a ready-to-copy message.
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: 🔴 Failure — Reviewer Handoff
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔙 ─── Rollback Instructions ─── 🔙"
|
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||||
echo ""
|
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
|
||||||
echo " # 1. Checkout previous version:"
|
echo "├─────────────────────────────────────────────────────────────┤"
|
||||||
echo " git checkout tags/\$(git describe --tags --abbrev=0 2>/dev/null || echo 'unknown')"
|
echo "│ │"
|
||||||
echo ""
|
echo "│ Version: v${{ steps.version.outputs.version }}"
|
||||||
echo " # 2. Redeploy:"
|
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||||
echo " cd /opt/openclaw/data/openclaw/workspace/nexus"
|
echo "│ │"
|
||||||
echo " docker compose up -d --force-recreate"
|
echo "│ → DevOps (Architekt) analysiert den Fehler │"
|
||||||
echo ""
|
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
|
||||||
echo " # 3. Or trigger rollback via Gitea:"
|
echo "│ → DevOps verifiziert mit neuem Deploy │"
|
||||||
echo " Trigger 'Deploy to Production' workflow with the previous tag"
|
echo "│ │"
|
||||||
echo ""
|
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
|
||||||
|
echo "│ workflow manuell in Gitea Actions. │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "└─────────────────────────────────────────────────────────────┘"
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
name: Rollback to Previous Version
|
||||||
|
run-name: 🔙 Rollback by @${{ gitea.actor }}
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
# Owner: DevOps (Architekt)
|
||||||
|
# Trigger: EXCLUSIVELY manual (workflow_dispatch).
|
||||||
|
#
|
||||||
|
# This workflow reverts the deploy path to the code at a
|
||||||
|
# given git tag/ref, then rebuilds and redeploys the stack.
|
||||||
|
#
|
||||||
|
# Strategy: git checkout <tag> → docker compose up -d --build
|
||||||
|
# This is a "full restart rollback" — safest for containerized
|
||||||
|
# apps where DB schema changes may need the matching API binary.
|
||||||
|
#
|
||||||
|
# DB migrations: the API runs MigrateAsync on startup. If the
|
||||||
|
# rollback-tag's migration history is a prefix of the current DB,
|
||||||
|
# EF Core handles this gracefully (no-op for already-applied
|
||||||
|
# migrations). If the tag predates a destructive migration, manual
|
||||||
|
# DB intervention is needed — that's an edge case surfaced to DevOps.
|
||||||
|
# ───────────────────────────────────────────────────────
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_tag:
|
||||||
|
description: 'Git tag to roll back to (e.g. v0.2.49)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
confirm:
|
||||||
|
description: 'Type "ROLLBACK" to confirm'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rollback:
|
||||||
|
name: Rollback Nexus
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
|
||||||
|
ENV_TMPFILE: /tmp/nexus-rollback-env
|
||||||
|
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
|
||||||
|
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
|
||||||
|
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
|
||||||
|
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 0: Safety gate — require explicit confirmation
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Safety Gate
|
||||||
|
run: |
|
||||||
|
if [ "${{ inputs.confirm }}" != "ROLLBACK" ]; then
|
||||||
|
echo "❌ Rollback aborted: confirmation string must be 'ROLLBACK'"
|
||||||
|
echo " You entered: '${{ inputs.confirm }}'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Rollback confirmed — proceeding to ${{ inputs.target_tag }}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 1: Checkout target tag
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Checkout target tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: refs/tags/${{ inputs.target_tag }}
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 2: Verify tag exists
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Verify tag
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTUAL_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
|
||||||
|
if [ -z "$ACTUAL_TAG" ]; then
|
||||||
|
echo "❌ Tag '${{ inputs.target_tag }}' not found in repository"
|
||||||
|
echo " Available tags:"
|
||||||
|
git tag -l 'v*' | sort -V | tail -20
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Checked out: $ACTUAL_TAG"
|
||||||
|
echo " Commit: $(git rev-parse --short HEAD)"
|
||||||
|
echo " Message: $(git log -1 --oneline)"
|
||||||
|
|
||||||
|
# Read version from VERSION file at this tag
|
||||||
|
if [ -f VERSION ]; then
|
||||||
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
|
echo " VERSION: $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 3: Prepare .env from secrets (safe temp file)
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Prepare .env (secrets → temp file)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cat > "${ENV_TMPFILE}" <<EOF
|
||||||
|
# Nexus Production Environment — auto-generated by CD pipeline
|
||||||
|
POSTGRES_DB=nexus
|
||||||
|
POSTGRES_USER=nexus
|
||||||
|
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
|
||||||
|
JWT_KEY=${ENV_JWT_KEY}
|
||||||
|
JWT_ISSUER=nexus
|
||||||
|
JWT_AUDIENCE=nexus-web
|
||||||
|
OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
|
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
|
||||||
|
OWNER_DISPLAY_NAME=
|
||||||
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
|
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 4: Sync rollback code to host
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Sync code to host
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker run --rm \
|
||||||
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
|
alpine:latest \
|
||||||
|
sh -c "
|
||||||
|
cd /src && \
|
||||||
|
find . -mindepth 1 -maxdepth 1 \
|
||||||
|
! -name .git \
|
||||||
|
-exec cp -r {} /dest/ \; && \
|
||||||
|
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||||
|
chown -R \"\$DEST_OWNER\" /dest
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 5: Rebuild & Redeploy
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Rebuild & Redeploy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
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 \
|
||||||
|
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
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "✅ Rollback redeploy completed"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 6: Clean up temp .env
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Clean up temp .env
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f "${ENV_TMPFILE}" ]; then
|
||||||
|
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
|
||||||
|
echo "🧹 Temp .env removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 7: Health Check
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Health Check
|
||||||
|
run: |
|
||||||
|
echo "🏥 Health check after rollback..."
|
||||||
|
RETRY=0
|
||||||
|
MAX=6
|
||||||
|
WAIT=1
|
||||||
|
while [ $RETRY -lt $MAX ]; do
|
||||||
|
RETRY=$((RETRY + 1))
|
||||||
|
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||||
|
sleep $WAIT
|
||||||
|
NEXT=$((WAIT + RETRY))
|
||||||
|
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
|
||||||
|
done
|
||||||
|
echo "❌ Health check failed after $MAX attempts"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 8: Smoke Test
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Smoke Test
|
||||||
|
run: |
|
||||||
|
echo "🔍 Smoke test after rollback..."
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
BASE="https://nexus.noveria.net"
|
||||||
|
|
||||||
|
check() {
|
||||||
|
local path="$1" label="$2" expected="${3:-200}"
|
||||||
|
local code
|
||||||
|
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
|
||||||
|
printf " %-25s HTTP %s" "${label}:" "${code}"
|
||||||
|
if [ "$code" = "$expected" ]; then
|
||||||
|
echo " ✅"
|
||||||
|
PASS=$((PASS + 1))
|
||||||
|
else
|
||||||
|
echo " ❌ (expected $expected)"
|
||||||
|
FAIL=$((FAIL + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check "/dashboard" "Dashboard" 200
|
||||||
|
check "/health" "Health API" 200
|
||||||
|
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
echo "❌ Smoke test failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Rollback to ${{ inputs.target_tag }} successful"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 9: Rollback Summary
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: Rollback Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " 🔙 Rollback Summary"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
echo " Rolled to: ${{ inputs.target_tag }}"
|
||||||
|
echo " Triggered: @${{ gitea.actor }}"
|
||||||
|
echo " Status: ${{ job.status }}"
|
||||||
|
echo "═══════════════════════════════════════"
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
# Step 10: Failure → Reviewer Handoff
|
||||||
|
# ═══════════════════════════════════════════════════
|
||||||
|
- name: 🔴 Rollback Failed — Reviewer Handoff
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "┌─────────────────────────────────────────────────────────────┐"
|
||||||
|
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
|
||||||
|
echo "├─────────────────────────────────────────────────────────────┤"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ Target: ${{ inputs.target_tag }}"
|
||||||
|
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ → DevOps (Architekt) analysiert den Fehler │"
|
||||||
|
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
|
||||||
|
echo "│ → DevOps verifiziert mit neuem Deploy │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "│ Letzter bekannter funktionierender Stand: │"
|
||||||
|
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
|
||||||
|
echo "│ → Manuellen Rollback erwägen: │"
|
||||||
|
echo "│ cd /opt/openclaw/data/openclaw/workspace/nexus │"
|
||||||
|
echo "│ docker compose up -d (vorheriger Stand) │"
|
||||||
|
echo "│ │"
|
||||||
|
echo "└─────────────────────────────────────────────────────────────┘"
|
||||||
@@ -31,3 +31,6 @@ docker-compose.override.yml
|
|||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
# pnpm (lockfile IS committed for reproducible CI builds)
|
# pnpm (lockfile IS committed for reproducible CI builds)
|
||||||
|
|
||||||
|
# Claude local config (per-developer, not repo-shared)
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
|
||||||
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
||||||
|
|
||||||
> CI/CD auto-deploy enabled — every push to main triggers build → test → deploy.
|
> CI runs automatically on every push. CD can run **automatically after successful CI**
|
||||||
|
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
|
||||||
|
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
|
||||||
|
> stay read-only. Rollback and database backup are separate manual workflows.
|
||||||
|
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
|
||||||
|
|
||||||
## Current foundation
|
## Current foundation
|
||||||
|
|
||||||
@@ -11,10 +15,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
|
|||||||
- ASP.NET Core 10 REST API (Minimal API pattern)
|
- ASP.NET Core 10 REST API (Minimal API pattern)
|
||||||
- Entity Framework Core and PostgreSQL
|
- Entity Framework Core and PostgreSQL
|
||||||
- JWT owner authentication with rotating refresh sessions
|
- JWT owner authentication with rotating refresh sessions
|
||||||
- `IAgentRuntime` abstraction with an OpenClaw adapter
|
- `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
|
||||||
- `IModelProvider` abstractions for Ollama and NVIDIA
|
|
||||||
- Responsive dark-mode operations dashboard
|
- Responsive dark-mode operations dashboard
|
||||||
- Container-only entry point on `127.0.0.1:18880`
|
- Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
|
||||||
|
|
||||||
## Local/container start
|
## Local/container start
|
||||||
|
|
||||||
@@ -27,12 +30,11 @@ curl http://127.0.0.1:18880/health
|
|||||||
```
|
```
|
||||||
|
|
||||||
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
|
||||||
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14
|
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
|
||||||
characters. Existing databases are never overwritten by the bootstrap process.
|
characters. Existing databases are never overwritten by the bootstrap process.
|
||||||
|
|
||||||
The web service is loopback-only. Public reverse-proxy activation for
|
The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
|
||||||
`nexus.noveria.net` remains a separate infrastructure change and must terminate
|
Health checks, rate limiting, and security headers are active.
|
||||||
TLS before forwarding to port `18880`.
|
|
||||||
|
|
||||||
## Workspace mounts
|
## Workspace mounts
|
||||||
|
|
||||||
@@ -41,12 +43,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
|
|||||||
|
|
||||||
| Host path | Container mount |
|
| Host path | Container mount |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
|
||||||
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
| `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
|
||||||
|
|
||||||
## Frontend architecture
|
## Frontend architecture
|
||||||
|
|
||||||
@@ -279,12 +281,72 @@ Backlog → Blocked → In progress / Done
|
|||||||
provider key. Conversation IDs are stable per browser and Iris is the default
|
provider key. Conversation IDs are stable per browser and Iris is the default
|
||||||
agent target.
|
agent target.
|
||||||
|
|
||||||
The configured model-routing policy is:
|
The configured model-routing policy routes through the OpenClaw Gateway only.
|
||||||
|
Ollama and NVIDIA providers have been removed. Currently active models:
|
||||||
|
|
||||||
1. `qwen3:4b` through Ollama for routine and monitoring work
|
| Agent | Model |
|
||||||
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work
|
|-------|-------|
|
||||||
3. `gpt-5.5` through OpenClaw for strategic and critical review
|
| Iris | `openai/gpt-5.4` |
|
||||||
|
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
|
||||||
|
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
|
||||||
|
|
||||||
|
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
|
||||||
|
|
||||||
The Settings module reports runtime and provider state without exposing
|
The Settings module reports runtime and provider state without exposing
|
||||||
credentials.
|
credentials.
|
||||||
# Trigger CI
|
|
||||||
|
## CI/CD
|
||||||
|
|
||||||
|
### CI — Automatic
|
||||||
|
|
||||||
|
Every push to `main` triggers `.gitea/workflows/ci.yaml`:
|
||||||
|
- **Backend**: .NET restore → build → test
|
||||||
|
- **Frontend**: pnpm install → type-check → test → build
|
||||||
|
- **Security**: Scan for hardcoded secrets in source code
|
||||||
|
|
||||||
|
CI must never break. If it does, Reviewer fixes.
|
||||||
|
|
||||||
|
### CD — Auto + Manual (CD v3)
|
||||||
|
|
||||||
|
Deployment can happen automatically or manually:
|
||||||
|
|
||||||
|
#### Auto-Deploy (after successful CI on main)
|
||||||
|
|
||||||
|
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
|
||||||
|
- Uses safe defaults: `patch` bump, all services, main ref
|
||||||
|
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
|
||||||
|
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
|
||||||
|
|
||||||
|
#### Manual Deploy (`workflow_dispatch`)
|
||||||
|
|
||||||
|
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
|
||||||
|
2. Chooses version bump type: patch (default) / minor / major
|
||||||
|
3. Optionally scopes to a single service or specific git ref
|
||||||
|
4. Workflow bumps VERSION, creates git tag, builds and deploys
|
||||||
|
5. Health check + smoke test verify the deployment
|
||||||
|
|
||||||
|
#### Rollback (`workflow_dispatch`)
|
||||||
|
|
||||||
|
1. DevOps triggers `Rollback to Previous Version` in Gitea Actions
|
||||||
|
2. Enters target git tag (e.g. `v0.2.49`) + confirmation `ROLLBACK`
|
||||||
|
3. Workflow checks out the tag, rebuilds with `--no-cache`, redeploys
|
||||||
|
4. Health check + smoke test verify the rollback
|
||||||
|
|
||||||
|
#### Database Backup (`workflow_dispatch`)
|
||||||
|
|
||||||
|
1. DevOps triggers `Database Backup` in Gitea Actions
|
||||||
|
2. Optionally also copies backup to a host path (`/home/projekte_bao/backups`)
|
||||||
|
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
|
||||||
|
4. Artifacts are retained for 90 days (configurable)
|
||||||
|
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
|
||||||
|
|
||||||
|
#### Failure Handling
|
||||||
|
|
||||||
|
When deploy or rollback fails:
|
||||||
|
- **DevOps (Architekt)** analyses the error
|
||||||
|
- **Reviewer (Code-Fixer)** fixes the problem
|
||||||
|
- **DevOps** re-deploys to verify the fix
|
||||||
|
|
||||||
|
The workflow outputs a formatted handoff message with the job URL.
|
||||||
|
|
||||||
|
Full CD documentation: [phases/deployment.md](phases/deployment.md)
|
||||||
|
|||||||
@@ -11,12 +11,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
public async Task GetAgentsAsync_ReturnsCorrectCount()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -27,12 +23,8 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
@@ -44,18 +36,60 @@ public class AgentServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
public async Task GetAgentAsync_Unknown_ReturnsNull()
|
||||||
{
|
{
|
||||||
var config = new ConfigurationBuilder()
|
var configPath = CreateAgentConfigFile();
|
||||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
var config = CreateConfiguration(configPath);
|
||||||
{
|
|
||||||
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
|
|
||||||
})
|
|
||||||
.Build();
|
|
||||||
var runtime = new FakeRuntime();
|
var runtime = new FakeRuntime();
|
||||||
var service = new AgentService(config, runtime);
|
var service = new AgentService(config, runtime);
|
||||||
|
|
||||||
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
|
||||||
Assert.Null(agent);
|
Assert.Null(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IConfiguration CreateConfiguration(string configPath)
|
||||||
|
=> new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["AgentConfigPath"] = configPath
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
private static string CreateAgentConfigFile()
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
|
||||||
|
File.WriteAllText(path,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"workspace": "/workspace/default",
|
||||||
|
"model": {
|
||||||
|
"primary": "deepseek/deepseek-v4-flash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": "iris",
|
||||||
|
"name": "iris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "programmer",
|
||||||
|
"name": "programmer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reviewer",
|
||||||
|
"name": "reviewer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "architekt",
|
||||||
|
"name": "architekt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class FakeRuntime : IAgentRuntime
|
public sealed class FakeRuntime : IAgentRuntime
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Nexus.Api.Controllers;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Tests;
|
||||||
|
|
||||||
|
public class OperationsSnapshotTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void GetSnapshot_RequiresAuthorization()
|
||||||
|
{
|
||||||
|
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
|
||||||
|
{
|
||||||
|
var guard = new RepositoryConcurrencyGuard();
|
||||||
|
var runtime = new SnapshotRuntimeStub();
|
||||||
|
var agentService = new SnapshotAgentServiceStub();
|
||||||
|
var projectRepo = new GuardedProjectRepository(guard);
|
||||||
|
var taskRepo = new GuardedTaskRepository(guard);
|
||||||
|
var activityRepo = new GuardedActivityRepository(guard);
|
||||||
|
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
|
||||||
|
|
||||||
|
await service.GetSnapshotAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, guard.MaxConcurrentCalls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class RepositoryConcurrencyGuard
|
||||||
|
{
|
||||||
|
private readonly Lock sync = new();
|
||||||
|
private int currentCalls;
|
||||||
|
|
||||||
|
public int MaxConcurrentCalls { get; private set; }
|
||||||
|
|
||||||
|
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
currentCalls++;
|
||||||
|
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(25, ct);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
lock (sync)
|
||||||
|
{
|
||||||
|
currentCalls--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
|
||||||
|
{
|
||||||
|
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<Project>
|
||||||
|
{
|
||||||
|
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
|
||||||
|
{
|
||||||
|
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<WorkTask>
|
||||||
|
{
|
||||||
|
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
|
||||||
|
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
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 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();
|
||||||
|
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
|
||||||
|
{
|
||||||
|
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
|
||||||
|
=> guard.RunAsync(new List<ActivityEvent>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotRuntimeStub : IAgentRuntime
|
||||||
|
{
|
||||||
|
public string Name => "stub";
|
||||||
|
|
||||||
|
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
|
||||||
|
|
||||||
|
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SnapshotAgentServiceStub : IAgentService
|
||||||
|
{
|
||||||
|
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
|
||||||
|
[
|
||||||
|
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
|
||||||
|
]);
|
||||||
|
|
||||||
|
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Helpers;
|
|
||||||
using Nexus.Api.Integrations;
|
using Nexus.Api.Integrations;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Repositories;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
@@ -15,6 +13,7 @@ public class AgentsController(
|
|||||||
IAgentService agentService,
|
IAgentService agentService,
|
||||||
IAgentRuntime runtime,
|
IAgentRuntime runtime,
|
||||||
IActivityRepository activityRepo,
|
IActivityRepository activityRepo,
|
||||||
|
IAgentConfigService agentConfigService,
|
||||||
ILogger<AgentsController> logger) : ControllerBase
|
ILogger<AgentsController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -22,8 +21,7 @@ public class AgentsController(
|
|||||||
{
|
{
|
||||||
var agents = await agentService.GetAgentsAsync(ct);
|
var agents = await agentService.GetAgentsAsync(ct);
|
||||||
return Results.Ok(agents.Select(a => new AgentListResponse(
|
return Results.Ok(agents.Select(a => new AgentListResponse(
|
||||||
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description
|
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
|
||||||
)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@@ -34,8 +32,7 @@ public class AgentsController(
|
|||||||
return Results.Ok(new AgentDetailResponse(
|
return Results.Ok(new AgentDetailResponse(
|
||||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
|
||||||
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
|
||||||
agent.SubAgents, agent.IdentityName
|
agent.SubAgents, agent.IdentityName));
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}/activity")]
|
[HttpGet("{id}/activity")]
|
||||||
@@ -58,9 +55,7 @@ public class AgentsController(
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
var result = await runtime.ChatAsync(message, conversationId, id, ct);
|
||||||
|
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
|
|
||||||
|
|
||||||
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
@@ -73,79 +68,52 @@ public class AgentsController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Agent Config Editor ==========
|
// ── Config Editor ──
|
||||||
|
|
||||||
[HttpGet("{id}/config")]
|
[HttpGet("{id}/config")]
|
||||||
public IResult GetConfig(string id)
|
public IResult GetConfig(string id)
|
||||||
{
|
=> Results.Ok(agentConfigService.GetConfigFiles(id));
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
|
||||||
if (!Directory.Exists(workspacePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
|
||||||
};
|
|
||||||
|
|
||||||
var files = Directory.GetFiles(workspacePath, "*.md")
|
|
||||||
.Select(f => new FileInfo(f))
|
|
||||||
.Where(f => allowedFiles.Contains(f.Name))
|
|
||||||
.OrderBy(f => f.Name)
|
|
||||||
.Select(f => new
|
|
||||||
{
|
|
||||||
fileName = f.Name,
|
|
||||||
size = f.Length,
|
|
||||||
modifiedAt = f.LastWriteTimeUtc
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Results.Ok(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{id}/config/{fileName}")]
|
[HttpGet("{id}/config/{fileName}")]
|
||||||
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
|
||||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
return file is null
|
||||||
|
? Results.NotFound()
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
: Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
|
|
||||||
var fi = new FileInfo(safePath!);
|
|
||||||
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}/config/{fileName}")]
|
[HttpPut("{id}/config/{fileName}")]
|
||||||
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
|
||||||
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
|
|
||||||
|
|
||||||
if (request.Content is null)
|
if (request.Content is null)
|
||||||
return Results.BadRequest(new { error = "Content is required." });
|
return Results.BadRequest(new { error = "Content is required." });
|
||||||
|
|
||||||
if (request.Content.Length > 500 * 1024)
|
if (request.Content.Length > 500 * 1024)
|
||||||
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
|
||||||
|
|
||||||
var workspacePath = $"/mnt/workspace-{id}";
|
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var tempPath = safePath + ".tmp";
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct);
|
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||||
System.IO.File.Move(tempPath, safePath, overwrite: true);
|
return result is null
|
||||||
|
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||||
|
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||||
}
|
}
|
||||||
catch
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath);
|
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||||
throw;
|
return Results.Problem(
|
||||||
|
title: "Permission denied",
|
||||||
|
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||||
|
return Results.Problem(
|
||||||
|
title: "File write error",
|
||||||
|
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
|
||||||
|
statusCode: StatusCodes.Status500InternalServerError);
|
||||||
}
|
}
|
||||||
|
|
||||||
var fi = new FileInfo(safePath);
|
|
||||||
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/calendar")]
|
[Route("api/v1/calendar")]
|
||||||
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase
|
public class CalendarController(ICalendarService calendarService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await calendarService.GetCronJobsAsync(ct));
|
||||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
|
||||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
|
||||||
httpClient.DefaultRequestHeaders.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
|
||||||
|
|
||||||
var response = await httpClient.GetAsync("/api/cron", ct);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
|
||||||
return Results.Ok(data ?? new List<CronJobEntry>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var fallbackJobs = new List<object>
|
|
||||||
{
|
|
||||||
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
|
|
||||||
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
|
|
||||||
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
|
|
||||||
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
|
|
||||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
|
|
||||||
};
|
|
||||||
return Results.Ok(fallbackJobs);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("upcoming")]
|
[HttpGet("upcoming")]
|
||||||
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
public async Task<IResult> GetUpcoming(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
|
||||||
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var httpClient = httpClientFactory.CreateClient("gateway");
|
|
||||||
if (!string.IsNullOrWhiteSpace(gatewayToken))
|
|
||||||
httpClient.DefaultRequestHeaders.Authorization =
|
|
||||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
|
|
||||||
|
|
||||||
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
|
||||||
return Results.Ok(data ?? new List<UpcomingCronEntry>());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
var fallback = new List<object>
|
|
||||||
{
|
|
||||||
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
|
|
||||||
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
|
|
||||||
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
|
|
||||||
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
|
|
||||||
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
|
|
||||||
};
|
|
||||||
return Results.Ok(fallback);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,403 +1,113 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.Models;
|
using Nexus.Api.Models;
|
||||||
using Nexus.Api.Repositories;
|
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/dashboard")]
|
[Route("api/dashboard")]
|
||||||
public class DashboardController(
|
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
|
||||||
OpenClawGatewayClient gateway,
|
|
||||||
ITaskRepository taskRepo,
|
|
||||||
IActivityRepository activityRepo,
|
|
||||||
ILogger<DashboardController> logger)
|
|
||||||
: ControllerBase
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Gateway health + session_status + subagents count.
|
|
||||||
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
public async Task<DashboardStatus> GetStatus()
|
public async Task<DashboardStatus> GetStatus()
|
||||||
{
|
=> await dashboardService.GetStatusAsync();
|
||||||
try
|
|
||||||
{
|
|
||||||
return await gateway.GetStatusAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard status check failed");
|
|
||||||
return new DashboardStatus(false, "Offline", 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns all agents with their current status.
|
|
||||||
/// Combines sessions_list + sub_agents_list.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("agents")]
|
[HttpGet("agents")]
|
||||||
public async Task<List<DashboardAgentInfo>> GetAgents()
|
public async Task<List<DashboardAgentInfo>> GetAgents()
|
||||||
{
|
=> await dashboardService.GetAgentsAsync();
|
||||||
try
|
|
||||||
{
|
|
||||||
return await gateway.GetAgentsAsync();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
|
||||||
return new List<DashboardAgentInfo>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the latest assistant messages aggregated from ALL agent sessions.
|
|
||||||
/// Events are sorted by timestamp descending (newest first).
|
|
||||||
/// Supports optional agent filter via ?agent= query parameter.
|
|
||||||
/// Falls back to Iris-only feed if multi-agent feed fails.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("operations")]
|
[HttpGet("operations")]
|
||||||
public async Task<List<FeedEntry>> GetOperations(
|
public async Task<List<FeedEntry>> GetOperations(
|
||||||
[FromQuery] int limit = 20,
|
[FromQuery] int limit = 20,
|
||||||
[FromQuery] string? agent = null)
|
[FromQuery] string? agent = null)
|
||||||
{
|
=> await dashboardService.GetOperationsAsync(limit, agent);
|
||||||
try
|
|
||||||
{
|
|
||||||
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
|
||||||
|
|
||||||
// Optional agent filter
|
|
||||||
if (!string.IsNullOrWhiteSpace(agent))
|
|
||||||
{
|
|
||||||
entries = entries
|
|
||||||
.Where(e => string.Equals(e.AgentId, agent, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(e.Agent, agent, StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
|
||||||
return new List<FeedEntry>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Send a chat message to the Iris session.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("chat/send")]
|
[HttpPost("chat/send")]
|
||||||
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Message))
|
if (string.IsNullOrWhiteSpace(request.Message))
|
||||||
return new ChatResponse(false, null, "Message is required");
|
return new ChatResponse(false, null, "Message is required");
|
||||||
|
|
||||||
try
|
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
|
||||||
{
|
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
|
||||||
var agentId = string.IsNullOrWhiteSpace(request.AgentId)
|
|
||||||
? "iris"
|
|
||||||
: request.AgentId.Trim();
|
|
||||||
|
|
||||||
return await gateway.SendChatMessageAsync(agentId, request.Message.Trim());
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard chat send failed");
|
|
||||||
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns chat messages (user + assistant only, not tool messages).
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("chat/messages")]
|
[HttpGet("chat/messages")]
|
||||||
public async Task<List<MessageEntry>> GetMessages(
|
public async Task<List<MessageEntry>> GetMessages(
|
||||||
[FromQuery] string? sessionKey,
|
[FromQuery] string? sessionKey,
|
||||||
[FromQuery] int limit = 50,
|
[FromQuery] int limit = 50,
|
||||||
[FromQuery] int offset = 0)
|
[FromQuery] int offset = 0)
|
||||||
{
|
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
|
||||||
try
|
|
||||||
{
|
|
||||||
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
|
||||||
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
|
||||||
|
|
||||||
return messages
|
|
||||||
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
|
||||||
return new List<MessageEntry>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns aggregated queue: cron jobs + open tasks (merged, sorted by priority).
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("queue")]
|
[HttpGet("queue")]
|
||||||
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
|
||||||
{
|
=> await dashboardService.GetQueueAsync(ct);
|
||||||
try
|
|
||||||
{
|
|
||||||
// Fetch cron jobs and open tasks concurrently
|
|
||||||
var cronTask = gateway.GetQueueAsync();
|
|
||||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
|
||||||
|
|
||||||
await Task.WhenAll(cronTask, tasksTask);
|
|
||||||
|
|
||||||
var cronJobs = cronTask.Result;
|
|
||||||
var openTasks = tasksTask.Result
|
|
||||||
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var merged = new List<QueueItem>();
|
|
||||||
|
|
||||||
// Map cron jobs (already in QueueItem format from gateway)
|
|
||||||
merged.AddRange(cronJobs);
|
|
||||||
|
|
||||||
// Map open tasks to QueueItems
|
|
||||||
foreach (var t in openTasks)
|
|
||||||
{
|
|
||||||
var priority = NormalizePriority(t.Priority);
|
|
||||||
merged.Add(new QueueItem(
|
|
||||||
"task-" + t.Id.ToString(),
|
|
||||||
t.Title,
|
|
||||||
t.State,
|
|
||||||
priority,
|
|
||||||
"task",
|
|
||||||
"--"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: high priority first, then medium, then low
|
|
||||||
var priorityOrder = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["high"] = 0,
|
|
||||||
["medium"] = 1,
|
|
||||||
["low"] = 2
|
|
||||||
};
|
|
||||||
|
|
||||||
return merged.OrderBy(q => priorityOrder.GetValueOrDefault(q.Priority, 99)).ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
|
||||||
return new List<QueueItem>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string NormalizePriority(string priority)
|
|
||||||
{
|
|
||||||
return priority.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"high" or "critical" or "urgent" => "high",
|
|
||||||
"low" or "minor" => "low",
|
|
||||||
_ => "medium"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Removes a queue item: cron jobs are deleted via gateway, tasks are set to Done.
|
|
||||||
/// </summary>
|
|
||||||
[HttpDelete("queue/{id}")]
|
[HttpDelete("queue/{id}")]
|
||||||
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
|
||||||
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
QueueDeleteOutcome.Deleted => NoContent(),
|
||||||
{
|
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
|
||||||
var ok = await gateway.DeleteCronJobAsync(id);
|
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
|
||||||
if (!ok)
|
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||||
return StatusCode(502, new { error = "Gateway could not delete cron job" });
|
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||||
return NoContent();
|
_ => StatusCode(500, new { error = "Internal error" })
|
||||||
}
|
};
|
||||||
else if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
// Extract the actual GUID from the prefixed id ("task-{guid}")
|
|
||||||
if (!id.StartsWith("task-"))
|
|
||||||
return BadRequest(new { error = "Invalid task id format" });
|
|
||||||
|
|
||||||
var guidStr = id["task-".Length..];
|
|
||||||
if (!Guid.TryParse(guidStr, out var guid))
|
|
||||||
return BadRequest(new { error = "Invalid task id" });
|
|
||||||
|
|
||||||
var task = await taskRepo.GetByIdAsync(guid, ct);
|
|
||||||
if (task is null)
|
|
||||||
return NotFound(new { error = "Task not found" });
|
|
||||||
|
|
||||||
// Set task status to Done instead of deleting
|
|
||||||
task.State = "Done";
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
|
||||||
{
|
|
||||||
Type = "task",
|
|
||||||
Message = $"Task \"{task.Title}\" completed via queue"
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: try cron
|
|
||||||
var deleted = await gateway.DeleteCronJobAsync(id);
|
|
||||||
if (!deleted)
|
|
||||||
return NotFound(new { error = "Queue item not found" });
|
|
||||||
return NoContent();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Delete queue item failed for {Id}", id);
|
|
||||||
return StatusCode(500, new { error = "Internal error" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Changes the priority of a queue item (only for tasks; cron jobs are ignored).
|
|
||||||
/// Cycles: high → medium → low → high.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPut("queue/{id}/priority")]
|
[HttpPut("queue/{id}/priority")]
|
||||||
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
|
||||||
|
return result.Outcome switch
|
||||||
{
|
{
|
||||||
if (!id.StartsWith("task-"))
|
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
|
||||||
return Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" });
|
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
|
||||||
|
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
|
||||||
var guidStr = id["task-".Length..];
|
_ => Ok(new { status = "ok", priority = result.NewPriority })
|
||||||
if (!Guid.TryParse(guidStr, out var guid))
|
|
||||||
return BadRequest(new { error = "Invalid task id" });
|
|
||||||
|
|
||||||
var task = await taskRepo.GetByIdAsync(guid, ct);
|
|
||||||
if (task is null)
|
|
||||||
return NotFound(new { error = "Task not found" });
|
|
||||||
|
|
||||||
// Cycle priority: high → medium → low → high
|
|
||||||
task.Priority = task.Priority.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"high" => "Medium",
|
|
||||||
"medium" => "Low",
|
|
||||||
"low" => "High",
|
|
||||||
_ => "Medium"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
|
||||||
{
|
|
||||||
Type = "task",
|
|
||||||
Message = $"Task \"{task.Title}\" priority → {task.Priority}"
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
return Ok(new { status = "ok", priority = task.Priority });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Change queue priority failed for {Id}", id);
|
|
||||||
return StatusCode(500, new { error = "Internal error" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the current model and provider for a specific agent session.
|
|
||||||
/// Calls session_status with the agent's session key.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("agents/{id}/model")]
|
[HttpGet("agents/{id}/model")]
|
||||||
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
|
||||||
{
|
{
|
||||||
try
|
var info = await dashboardService.GetAgentModelAsync(id);
|
||||||
{
|
return info is null
|
||||||
var info = await gateway.GetAgentModelAsync(id);
|
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
|
||||||
if (info is null)
|
: Ok(info);
|
||||||
return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" });
|
|
||||||
return Ok(info);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id);
|
|
||||||
return StatusCode(500, new { error = "Internal error" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sets the model for a specific agent session.
|
|
||||||
/// Calls session_status with model parameter.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPut("agents/{id}/model")]
|
[HttpPut("agents/{id}/model")]
|
||||||
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request.Model))
|
if (string.IsNullOrWhiteSpace(request.Model))
|
||||||
return BadRequest(new { error = "Model is required" });
|
return BadRequest(new { error = "Model is required" });
|
||||||
|
|
||||||
try
|
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
|
||||||
{
|
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
|
||||||
var ok = await gateway.SetAgentModelAsync(id, request.Model);
|
|
||||||
if (!ok)
|
|
||||||
return StatusCode(502, new { error = "Gateway did not accept the change" });
|
|
||||||
return Ok(new { status = "ok", model = request.Model });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id);
|
|
||||||
return StatusCode(500, new { error = "Internal error" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the most recent activity entries (assistant messages) for a specific agent.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("agents/{id}/activity")]
|
[HttpGet("agents/{id}/activity")]
|
||||||
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
|
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
|
||||||
{
|
=> await dashboardService.GetAgentActivityAsync(id, limit);
|
||||||
try
|
|
||||||
{
|
|
||||||
return await gateway.GetAgentActivityAsync(id, Math.Clamp(limit, 1, 20));
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", id);
|
|
||||||
return new List<AgentActivityEntry>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the list of available models that can be assigned to agents.
|
|
||||||
/// Reads from OpenClaw config dynamically, falls back to hardcoded list.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("models")]
|
[HttpGet("models")]
|
||||||
public ActionResult<List<ModelOption>> GetAvailableModels()
|
public ActionResult<List<ModelOption>> GetAvailableModels()
|
||||||
{
|
=> Ok(dashboardService.GetAvailableModels());
|
||||||
var models = gateway.GetAvailableModels();
|
|
||||||
return Ok(models);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Task Endpoints ==========
|
// ── Task Endpoints ──
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns all non-done tasks (status != 'Done'), ordered by creation date descending.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet("tasks")]
|
[HttpGet("tasks")]
|
||||||
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
|
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
var tasks = await taskService.GetOpenAsync(ct);
|
||||||
{
|
return tasks.Select(MapToDto).ToList();
|
||||||
var tasks = await taskRepo.GetAllAsync(ct);
|
|
||||||
return tasks
|
|
||||||
.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
|
||||||
.OrderByDescending(t => t.CreatedAt)
|
|
||||||
.Select(MapToDto)
|
|
||||||
.ToList();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogWarning(ex, "Dashboard tasks fetch failed");
|
|
||||||
return new List<DashboardTaskDto>();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new task and logs an activity event.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost("tasks")]
|
[HttpPost("tasks")]
|
||||||
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
|
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
|
||||||
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
|
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
|
||||||
@@ -405,121 +115,49 @@ public class DashboardController(
|
|||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
return BadRequest(new { error = "Title is required." });
|
return BadRequest(new { error = "Title is required." });
|
||||||
|
|
||||||
var task = new WorkTask
|
var task = await taskService.CreateDashboardTaskAsync(
|
||||||
{
|
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||||
Title = request.Title.Trim(),
|
|
||||||
Detail = request.Detail?.Trim(),
|
|
||||||
Source = string.IsNullOrWhiteSpace(request.Source) ? "bao" : request.Source.Trim(),
|
|
||||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
|
||||||
AssignedTo = request.AssignedTo?.Trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await taskRepo.AddAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
|
||||||
{
|
|
||||||
Type = "task",
|
|
||||||
Message = $"Task \"{task.Title}\" created ({task.Source})"
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Updates an existing task (title, detail, source, priority, assignedTo).
|
|
||||||
/// </summary>
|
|
||||||
[HttpPut("tasks/{id:guid}")]
|
[HttpPut("tasks/{id:guid}")]
|
||||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
|
||||||
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.UpdateDashboardTaskAsync(
|
||||||
if (task is null)
|
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
|
||||||
return NotFound(new { error = "Task not found." });
|
return result.Outcome switch
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
|
||||||
task.Title = request.Title.Trim();
|
|
||||||
if (request.Detail is not null)
|
|
||||||
task.Detail = string.IsNullOrWhiteSpace(request.Detail) ? null : request.Detail.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Source))
|
|
||||||
task.Source = request.Source.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
|
||||||
task.Priority = request.Priority.Trim();
|
|
||||||
if (request.AssignedTo is not null)
|
|
||||||
task.AssignedTo = string.IsNullOrWhiteSpace(request.AssignedTo) ? null : request.AssignedTo.Trim();
|
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
|
||||||
{
|
{
|
||||||
Type = "task",
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
Message = $"Task \"{task.Title}\" updated"
|
_ => Ok(MapToDto(result.Task!))
|
||||||
}, ct);
|
};
|
||||||
|
|
||||||
return Ok(MapToDto(task));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Deletes a task (only if status is 'Done' or 'Backlog').
|
|
||||||
/// </summary>
|
|
||||||
[HttpDelete("tasks/{id:guid}")]
|
[HttpDelete("tasks/{id:guid}")]
|
||||||
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
|
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.DeleteAsync(id, ct);
|
||||||
if (task is null)
|
return result.Outcome switch
|
||||||
return NotFound(new { error = "Task not found." });
|
|
||||||
|
|
||||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
|
||||||
return StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." });
|
|
||||||
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
|
||||||
{
|
{
|
||||||
Type = "task",
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
Message = $"Task \"{task.Title}\" deleted"
|
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
|
||||||
}, ct);
|
_ => NoContent()
|
||||||
await taskRepo.DeleteAsync(task, ct);
|
};
|
||||||
|
|
||||||
return NoContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Changes the status of a task.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPatch("tasks/{id:guid}/status")]
|
[HttpPatch("tasks/{id:guid}/status")]
|
||||||
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
|
||||||
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!TaskStateHelper.IsValidState(request.Status))
|
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
|
||||||
return BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" });
|
return result.Outcome switch
|
||||||
|
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
|
||||||
if (task is null)
|
|
||||||
return NotFound(new { error = "Task not found." });
|
|
||||||
|
|
||||||
var canonicalState = TaskStateHelper.AllStates.First(s =>
|
|
||||||
s.Equals(request.Status, StringComparison.OrdinalIgnoreCase));
|
|
||||||
task.State = canonicalState;
|
|
||||||
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent
|
|
||||||
{
|
{
|
||||||
Type = "task",
|
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
|
||||||
Message = $"Task \"{task.Title}\" → {canonicalState}"
|
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||||
}, ct);
|
_ => Ok(MapToDto(result.Task!))
|
||||||
|
};
|
||||||
return Ok(MapToDto(task));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Helpers ==========
|
|
||||||
|
|
||||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||||
t.Id,
|
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
|
||||||
t.Title,
|
|
||||||
t.Detail,
|
|
||||||
t.Source,
|
|
||||||
t.State,
|
|
||||||
t.Priority,
|
|
||||||
t.AssignedTo,
|
|
||||||
t.CreatedAt,
|
|
||||||
t.UpdatedAt
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/docs")]
|
[Route("api/v1/docs")]
|
||||||
public class DocsController : ControllerBase
|
public class DocsController(IDocService docService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IResult GetAll()
|
public IResult GetAll()
|
||||||
{
|
=> Results.Ok(docService.GetAll());
|
||||||
var workspaceRoot = "/mnt/workspace-iris";
|
|
||||||
var results = new List<object>();
|
|
||||||
|
|
||||||
void ScanDir(string dir, string category)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dir)) return;
|
|
||||||
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
|
||||||
{
|
|
||||||
var ext = Path.GetExtension(file).ToLowerInvariant();
|
|
||||||
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
|
|
||||||
continue;
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
results.Add(new
|
|
||||||
{
|
|
||||||
name = fi.Name,
|
|
||||||
path = file.Replace(workspaceRoot, "").TrimStart('/'),
|
|
||||||
category,
|
|
||||||
type = ext.Replace(".", ""),
|
|
||||||
size = fi.Length,
|
|
||||||
modifiedAt = fi.LastWriteTimeUtc
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
|
|
||||||
ScanDir("/mnt/workspace-iris/skills", "skills");
|
|
||||||
ScanDir("/mnt/workspace-iris", "workspace");
|
|
||||||
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
|
|
||||||
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
|
|
||||||
|
|
||||||
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{**path}")]
|
[HttpGet("{**path}")]
|
||||||
public async Task<IResult> GetFile(string path)
|
public async Task<IResult> GetFile(string path)
|
||||||
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(path))
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
return Results.BadRequest("Path required.");
|
return Results.BadRequest("Path required.");
|
||||||
|
|
||||||
string? resolvedPath = null;
|
var file = await docService.GetFileAsync(path);
|
||||||
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" })
|
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||||
{
|
|
||||||
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
|
|
||||||
{
|
|
||||||
resolvedPath = candidate;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resolvedPath is null)
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
|
|
||||||
var fi = new FileInfo(resolvedPath);
|
|
||||||
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ namespace Nexus.Api.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpGet("/health/live")]
|
||||||
|
public IResult Live()
|
||||||
|
{
|
||||||
|
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("/health")]
|
[HttpGet("/health")]
|
||||||
public async Task<IResult> Get(CancellationToken ct)
|
public async Task<IResult> Get(CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,100 +1,20 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/incidents")]
|
[Route("api/v1/incidents")]
|
||||||
public class IncidentsController : ControllerBase
|
public class IncidentsController(IIncidentService incidentService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll()
|
public async Task<IResult> GetAll()
|
||||||
{
|
=> Results.Ok(await incidentService.GetAllAsync());
|
||||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
|
||||||
if (!Directory.Exists(basePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var incidents = new List<object>();
|
|
||||||
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
if (fi.Length > 1_000_000) continue;
|
|
||||||
var name = Path.GetFileNameWithoutExtension(file);
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(file);
|
|
||||||
|
|
||||||
var title = name;
|
|
||||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
|
||||||
if (titleMatch.Success)
|
|
||||||
title = titleMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var date = (string?)null;
|
|
||||||
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
|
|
||||||
if (dateMatch.Success)
|
|
||||||
date = dateMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
var severity = "unknown";
|
|
||||||
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
|
|
||||||
if (severityMatch.Success)
|
|
||||||
severity = severityMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
|
||||||
var excerpt = excerptEnd > 0
|
|
||||||
? content[..excerptEnd].Trim()
|
|
||||||
: content[..Math.Min(300, content.Length)].Trim();
|
|
||||||
if (excerpt.Length > 200)
|
|
||||||
excerpt = excerpt[..200] + "\u2026";
|
|
||||||
|
|
||||||
incidents.Add(new
|
|
||||||
{
|
|
||||||
name = Path.GetFileName(file),
|
|
||||||
title,
|
|
||||||
date,
|
|
||||||
severity,
|
|
||||||
excerpt,
|
|
||||||
size = fi.Length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(incidents);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
public async Task<IResult> GetOne(string name)
|
public async Task<IResult> GetOne(string name)
|
||||||
{
|
{
|
||||||
var basePath = "/mnt/workspace-iris/memory/incidents";
|
var incident = await incidentService.GetByNameAsync(name);
|
||||||
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath))
|
return incident is null ? Results.NotFound() : Results.Ok(incident);
|
||||||
return Results.BadRequest("Invalid filename.");
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
{
|
|
||||||
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
|
||||||
filePath = Path.Combine(basePath, name + ".md");
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
return Results.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
|
||||||
var fi = new FileInfo(filePath!);
|
|
||||||
var fileName = Path.GetFileName(filePath!);
|
|
||||||
|
|
||||||
var title = fileName;
|
|
||||||
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
|
|
||||||
if (titleMatch.Success)
|
|
||||||
title = titleMatch.Groups[1].Value.Trim();
|
|
||||||
|
|
||||||
var date = (string?)null;
|
|
||||||
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
|
|
||||||
if (dateMatch.Success)
|
|
||||||
date = dateMatch.Groups[1].Value;
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
name = fileName,
|
|
||||||
title,
|
|
||||||
date,
|
|
||||||
content,
|
|
||||||
size = fi.Length
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Helpers;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/memory")]
|
[Route("api/v1/memory")]
|
||||||
public class MemoryController : ControllerBase
|
public class MemoryController(IMemoryService memoryService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public IResult GetAll()
|
public async Task<IResult> GetAll()
|
||||||
{
|
=> Results.Ok(await memoryService.GetAllAsync());
|
||||||
var basePath = "/mnt/workspace-iris/memory";
|
|
||||||
if (!Directory.Exists(basePath))
|
|
||||||
return Results.Ok(Array.Empty<object>());
|
|
||||||
|
|
||||||
var files = Directory.GetFiles(basePath, "*.md")
|
|
||||||
.Select(f => new FileInfo(f))
|
|
||||||
.OrderByDescending(f => f.Name)
|
|
||||||
.Select(f => new
|
|
||||||
{
|
|
||||||
name = f.Name,
|
|
||||||
path = f.FullName.Replace(basePath, "").TrimStart('/'),
|
|
||||||
size = f.Length,
|
|
||||||
modifiedAt = f.LastWriteTimeUtc
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (System.IO.File.Exists(longTermPath))
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(longTermPath);
|
|
||||||
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(files);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("search")]
|
[HttpGet("search")]
|
||||||
public async Task<IResult> Search([FromQuery] string q)
|
public async Task<IResult> Search([FromQuery] string q)
|
||||||
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
|
||||||
return Results.BadRequest("Query must be at least 2 characters.");
|
return Results.BadRequest("Query must be at least 2 characters.");
|
||||||
|
|
||||||
var basePath = "/mnt/workspace-iris/memory";
|
return Results.Ok(await memoryService.SearchAsync(q));
|
||||||
var results = new List<object>();
|
|
||||||
|
|
||||||
const int maxFiles = 50;
|
|
||||||
const int maxFileSize = 1_000_000;
|
|
||||||
|
|
||||||
async Task SearchDir(string dir)
|
|
||||||
{
|
|
||||||
if (!Directory.Exists(dir)) return;
|
|
||||||
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
|
|
||||||
foreach (var file in files)
|
|
||||||
{
|
|
||||||
var fi = new FileInfo(file);
|
|
||||||
if (fi.Length > maxFileSize) continue;
|
|
||||||
string content;
|
|
||||||
using (var reader = new StreamReader(file))
|
|
||||||
content = await reader.ReadToEndAsync();
|
|
||||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
|
||||||
var start = Math.Max(0, idx - 60);
|
|
||||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
|
||||||
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await SearchDir(basePath);
|
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (System.IO.File.Exists(longTermPath))
|
|
||||||
{
|
|
||||||
string content;
|
|
||||||
using (var reader = new StreamReader(longTermPath))
|
|
||||||
content = await reader.ReadToEndAsync();
|
|
||||||
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
|
|
||||||
var start = Math.Max(0, idx - 60);
|
|
||||||
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
|
|
||||||
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(results);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{name}")]
|
[HttpGet("{name}")]
|
||||||
public async Task<IResult> GetFile(string name)
|
public async Task<IResult> GetFile(string name)
|
||||||
{
|
{
|
||||||
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath))
|
var file = await memoryService.GetFileAsync(name);
|
||||||
return Results.BadRequest("Invalid filename.");
|
return file is null ? Results.NotFound() : Results.Ok(file);
|
||||||
|
|
||||||
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
|
|
||||||
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
|
||||||
filePath = longTermPath;
|
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath!))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(filePath!);
|
|
||||||
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,15 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.Integrations;
|
|
||||||
using Nexus.Api.Repositories;
|
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/operations")]
|
[Route("api/v1/operations")]
|
||||||
public class OperationsController(
|
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
||||||
IAgentRuntime runtime,
|
|
||||||
IAgentService agentService,
|
|
||||||
IProjectRepository projectRepo,
|
|
||||||
ITaskRepository taskRepo,
|
|
||||||
IActivityRepository activityRepo) : ControllerBase
|
|
||||||
{
|
{
|
||||||
[HttpGet("snapshot")]
|
[HttpGet("snapshot")]
|
||||||
|
[Authorize]
|
||||||
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
public async Task<IResult> GetSnapshot(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||||
var runtimeTask = runtime.GetStatusAsync(ct);
|
|
||||||
var agentsTask = agentService.GetAgentsAsync(ct);
|
|
||||||
var projectsTask = projectRepo.GetAllAsync(ct);
|
|
||||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
|
||||||
var activityTask = activityRepo.GetRecentAsync(20, ct);
|
|
||||||
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
|
|
||||||
|
|
||||||
var tasks = tasksTask.Result;
|
|
||||||
var projects = projectsTask.Result;
|
|
||||||
var agents = agentsTask.Result;
|
|
||||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
|
||||||
|
|
||||||
var runtimeStatus = runtimeTask.Result;
|
|
||||||
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
|
|
||||||
|
|
||||||
var lastIncident = tasks
|
|
||||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
|
||||||
.OrderByDescending(x => x.UpdatedAt)
|
|
||||||
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
var projectHealth = new
|
|
||||||
{
|
|
||||||
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
|
||||||
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
|
||||||
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
|
||||||
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
|
||||||
};
|
|
||||||
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
generatedAt = DateTimeOffset.UtcNow,
|
|
||||||
runtime = runtimeStatus,
|
|
||||||
models = Array.Empty<object>(),
|
|
||||||
runtimeHealthy,
|
|
||||||
metrics = new
|
|
||||||
{
|
|
||||||
activeAgents = agents.Count,
|
|
||||||
queuedTasks = tasks.Count - completedTasks,
|
|
||||||
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
|
||||||
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
|
||||||
},
|
|
||||||
lastIncident,
|
|
||||||
projectHealth,
|
|
||||||
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
|
||||||
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
|
|
||||||
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
|
|
||||||
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/projects")]
|
[Route("api/v1/projects")]
|
||||||
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase
|
public class ProjectsController(IProjectService projectService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
=> Results.Ok(await projectRepo.GetAllAsync(ct));
|
=> Results.Ok(await projectService.GetAllAsync(ct));
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var project = await projectService.GetByIdAsync(id, ct);
|
||||||
|
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
|
||||||
@@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
|
|||||||
if (string.IsNullOrWhiteSpace(request.Name))
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
|
||||||
|
|
||||||
var project = new Project
|
var project = await projectService.CreateAsync(request, ct);
|
||||||
{
|
|
||||||
Name = request.Name.Trim(),
|
|
||||||
Description = request.Description?.Trim() ?? string.Empty,
|
|
||||||
Status = OperationalStatus.Online
|
|
||||||
};
|
|
||||||
await projectRepo.AddAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
|
||||||
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
return Results.Created($"/api/v1/projects/{project.Id}", project);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:guid}")]
|
|
||||||
public async Task<IResult> GetById(Guid id, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
|
||||||
return project is null ? Results.NotFound() : Results.Ok(project);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{id:guid}")]
|
||||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
var project = await projectService.UpdateAsync(id, request, ct);
|
||||||
if (project is null) return Results.NotFound();
|
return project is null ? Results.NotFound() : Results.Ok(project);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
project.Name = request.Name.Trim();
|
|
||||||
if (request.Description is not null)
|
|
||||||
project.Description = request.Description.Trim();
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
|
||||||
project.Status = parsedStatus;
|
|
||||||
|
|
||||||
await projectRepo.UpdateAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
|
||||||
return Results.Ok(project);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
[HttpDelete("{id:guid}")]
|
||||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var project = await projectRepo.GetByIdAsync(id, ct);
|
var result = await projectService.DeleteAsync(id, ct);
|
||||||
if (project is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
|
||||||
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
|
|
||||||
if (hasTasks)
|
|
||||||
{
|
{
|
||||||
project.Status = OperationalStatus.Offline;
|
ProjectDeleteOutcome.NotFound => Results.NotFound(),
|
||||||
await projectRepo.UpdateAsync(project, ct);
|
ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
_ => Results.NoContent()
|
||||||
return Results.Ok(project);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
await projectRepo.DeleteAsync(project, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Data;
|
using Nexus.Api.Data;
|
||||||
using Nexus.Api.DTOs;
|
using Nexus.Api.DTOs;
|
||||||
using Nexus.Api.Repositories;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
namespace Nexus.Api.Controllers;
|
namespace Nexus.Api.Controllers;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/tasks")]
|
[Route("api/v1/tasks")]
|
||||||
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase
|
public class TasksController(ITaskService taskService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetAll(CancellationToken ct)
|
public async Task<IResult> GetAll(CancellationToken ct)
|
||||||
=> Results.Ok(await taskRepo.GetAllAsync(ct));
|
=> Results.Ok(await taskService.GetAllAsync(ct));
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
|
||||||
@@ -19,107 +19,84 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
|
|||||||
if (string.IsNullOrWhiteSpace(request.Title))
|
if (string.IsNullOrWhiteSpace(request.Title))
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
|
||||||
|
|
||||||
var task = new WorkTask
|
var task = await taskService.CreateAsync(request, ct);
|
||||||
{
|
|
||||||
Title = request.Title.Trim(),
|
|
||||||
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
|
||||||
ProjectId = request.ProjectId
|
|
||||||
};
|
|
||||||
await taskRepo.AddAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
|
||||||
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
return Results.Created($"/api/v1/tasks/{task.Id}", task);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("pending-approval")]
|
[HttpGet("pending-approval")]
|
||||||
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
public async Task<IResult> GetPendingApproval(CancellationToken ct)
|
||||||
{
|
{
|
||||||
var pending = await taskRepo.GetPendingApprovalAsync(ct);
|
var pending = await taskService.GetPendingApprovalAsync(ct);
|
||||||
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/approve")]
|
[HttpPost("{id:guid}/approve")]
|
||||||
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
public async Task<IResult> Approve(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.ApproveAsync(id, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
return Results.Problem(
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
title: "Approval denied",
|
title: "Approval denied",
|
||||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
};
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
|
||||||
return Results.Ok(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/reject")]
|
[HttpPost("{id:guid}/reject")]
|
||||||
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
public async Task<IResult> Reject(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.RejectAsync(id, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
return Results.Problem(
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
title: "Rejection denied",
|
title: "Rejection denied",
|
||||||
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
};
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
|
||||||
return Results.Ok(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}/state")]
|
[HttpPatch("{id:guid}/state")]
|
||||||
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var allowedStates = TaskStateHelper.AllStates;
|
if (!TaskStateHelper.IsValidState(request.State))
|
||||||
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
|
|
||||||
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
|
||||||
|
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.UpdateStateAsync(id, request.State, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase));
|
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
|
||||||
return Results.Ok(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id:guid}")]
|
|
||||||
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
if (task is null) return Results.NotFound();
|
_ => Results.Ok(result.Task)
|
||||||
|
};
|
||||||
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
|
||||||
return Results.Problem(
|
|
||||||
title: "Task deletion denied",
|
|
||||||
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
|
||||||
statusCode: StatusCodes.Status403Forbidden);
|
|
||||||
|
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
|
||||||
await taskRepo.DeleteAsync(task, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id:guid}")]
|
[HttpPatch("{id:guid}")]
|
||||||
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
var result = await taskService.UpdateAsync(id, request, ct);
|
||||||
if (task is null) return Results.NotFound();
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
|
_ => Results.Ok(result.Task)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
[HttpDelete("{id:guid}")]
|
||||||
task.Title = request.Title.Trim();
|
public async Task<IResult> Delete(Guid id, CancellationToken ct)
|
||||||
if (!string.IsNullOrWhiteSpace(request.Priority))
|
{
|
||||||
task.Priority = request.Priority.Trim();
|
var result = await taskService.DeleteAsync(id, ct);
|
||||||
if (request.ProjectId.HasValue)
|
return result.Outcome switch
|
||||||
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
{
|
||||||
|
TaskOperationOutcome.NotFound => Results.NotFound(),
|
||||||
await taskRepo.UpdateAsync(task, ct);
|
TaskOperationOutcome.InvalidState => Results.Problem(
|
||||||
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
title: "Task deletion denied",
|
||||||
return Results.Ok(task);
|
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden),
|
||||||
|
_ => Results.NoContent()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1/team")]
|
[Route("api/v1/team")]
|
||||||
public class TeamController(IAgentService agentService) : ControllerBase
|
public class TeamController(ITeamService teamService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IResult> GetTeam(CancellationToken ct)
|
public async Task<IResult> GetTeam(CancellationToken ct)
|
||||||
{
|
=> Results.Ok(await teamService.GetTeamAsync(ct));
|
||||||
var agents = await agentService.GetAgentsAsync(ct);
|
|
||||||
var team = new List<object>();
|
|
||||||
|
|
||||||
foreach (var agent in agents)
|
|
||||||
{
|
|
||||||
string identity = "";
|
|
||||||
string workspace = agent.Workspace ?? "";
|
|
||||||
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
|
|
||||||
{
|
|
||||||
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
|
||||||
if (System.IO.File.Exists(identityFile))
|
|
||||||
{
|
|
||||||
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
|
|
||||||
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
|
|
||||||
identity = string.Join("\n", lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
team.Add(new
|
|
||||||
{
|
|
||||||
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
|
||||||
identity
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(team);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Helpers;
|
||||||
|
using Nexus.Api.Middleware;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for configuring the Nexus application pipeline and startup.
|
||||||
|
/// </summary>
|
||||||
|
public static class ApplicationBuilderExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task EnsureDatabaseAsync(this WebApplication app)
|
||||||
|
{
|
||||||
|
var configuration = app.Configuration;
|
||||||
|
|
||||||
|
await using (var scope = app.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
|
||||||
|
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
||||||
|
var ownerPassword = configuration["Owner:Password"];
|
||||||
|
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
|
||||||
|
var hasUsers = await db.Users.AnyAsync();
|
||||||
|
|
||||||
|
if (!hasUsers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ownerEmail))
|
||||||
|
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
||||||
|
|
||||||
|
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
||||||
|
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
|
||||||
|
: ownerDisplayName;
|
||||||
|
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
||||||
|
? PasswordHelper.GenerateTemporaryPassword()
|
||||||
|
: ownerPassword;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
|
||||||
|
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
|
||||||
|
|
||||||
|
db.Users.Add(new NexusUser
|
||||||
|
{
|
||||||
|
Email = ownerEmail,
|
||||||
|
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
||||||
|
DisplayName = initialDisplayName,
|
||||||
|
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||||
|
Role = "owner"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ownerPassword))
|
||||||
|
{
|
||||||
|
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
|
||||||
|
/// </summary>
|
||||||
|
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
|
||||||
|
{
|
||||||
|
app.UseForwardedHeaders();
|
||||||
|
app.UseRateLimiter();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
app.UseSecurityHeaders();
|
||||||
|
|
||||||
|
if (env.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
using Nexus.Api.Routing;
|
||||||
|
using Nexus.Api.Services;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading.RateLimiting;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extension methods for registering Nexus application services in the DI container.
|
||||||
|
/// </summary>
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Configures JWT authentication, authorization, and antiforgery.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
var jwtKey = configuration["Jwt:Key"];
|
||||||
|
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
|
||||||
|
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
|
||||||
|
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
||||||
|
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
||||||
|
|
||||||
|
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer(options =>
|
||||||
|
{
|
||||||
|
options.MapInboundClaims = false;
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = true,
|
||||||
|
ValidateAudience = true,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
ValidateIssuerSigningKey = true,
|
||||||
|
ValidIssuer = jwtIssuer,
|
||||||
|
ValidAudience = jwtAudience,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||||
|
NameClaimType = JwtRegisteredClaimNames.Sub,
|
||||||
|
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
||||||
|
ClockSkew = TimeSpan.FromSeconds(30)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
services.AddAntiforgery(options =>
|
||||||
|
{
|
||||||
|
options.HeaderName = "X-CSRF-TOKEN";
|
||||||
|
options.Cookie.Name = "nexus-csrf";
|
||||||
|
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||||
|
options.Cookie.HttpOnly = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures rate limiting policies (auth and agents).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddRateLimiter(options =>
|
||||||
|
{
|
||||||
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 5,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
|
||||||
|
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 30,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures forwarded headers for reverse proxy scenarios.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
{
|
||||||
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
options.KnownIPNetworks.Clear();
|
||||||
|
options.KnownProxies.Clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures Swagger and JSON serialization options.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddEndpointsApiExplorer();
|
||||||
|
services.AddSwaggerGen();
|
||||||
|
services.ConfigureHttpJsonOptions(options =>
|
||||||
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the Entity Framework Core DbContext with Npgsql.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddDbContext<NexusDbContext>(options =>
|
||||||
|
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
|
||||||
|
.ConfigureWarnings(w => w.Ignore(
|
||||||
|
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers typed and named HTTP clients for OpenClaw integration.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient("gateway", client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||||
|
?? "http://127.0.0.1:18789");
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers application domain services (transient, scoped, singleton).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddTransient<ModelRoutingService>();
|
||||||
|
services.AddScoped<IAuthService, AuthService>();
|
||||||
|
services.AddScoped<IAgentService, AgentService>();
|
||||||
|
services.AddScoped<IDashboardService, DashboardService>();
|
||||||
|
services.AddScoped<IProjectService, ProjectService>();
|
||||||
|
services.AddScoped<ITaskService, TaskService>();
|
||||||
|
services.AddScoped<IOperationsService, OperationsService>();
|
||||||
|
services.AddScoped<ITeamService, TeamService>();
|
||||||
|
services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||||
|
services.AddSingleton<IMemoryService, MemoryService>();
|
||||||
|
services.AddSingleton<IIncidentService, IncidentService>();
|
||||||
|
services.AddSingleton<IDocService, DocService>();
|
||||||
|
services.AddScoped<ICalendarService, CalendarService>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers data repositories.
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IUserRepository, UserRepository>();
|
||||||
|
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||||
|
services.AddScoped<ITaskRepository, TaskRepository>();
|
||||||
|
services.AddScoped<IActivityRepository, ActivityRepository>();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Configures health checks (PostgreSQL connectivity and runtime status).
|
||||||
|
/// </summary>
|
||||||
|
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddHealthChecks()
|
||||||
|
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
||||||
|
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper methods for password generation and name construction.
|
||||||
|
/// </summary>
|
||||||
|
public static class PasswordHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
|
||||||
|
/// </summary>
|
||||||
|
public static string GenerateTemporaryPassword()
|
||||||
|
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a human-readable display name from an email address.
|
||||||
|
/// </summary>
|
||||||
|
public static string BuildOwnerDisplayName(string email)
|
||||||
|
{
|
||||||
|
var localPart = email.Split('@', 2)[0].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
||||||
|
|
||||||
|
var words = localPart
|
||||||
|
.Replace('.', ' ')
|
||||||
|
.Replace('_', ' ')
|
||||||
|
.Replace('-', ' ')
|
||||||
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
||||||
|
|
||||||
|
var displayName = string.Join(' ', words);
|
||||||
|
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,12 @@ public sealed record DashboardAgentInfo(
|
|||||||
string[] Tags,
|
string[] Tags,
|
||||||
int Progress = 0,
|
int Progress = 0,
|
||||||
int Workload = 0,
|
int Workload = 0,
|
||||||
string? Goal = null
|
string? Goal = null,
|
||||||
|
string RoleBadge = "badge-slate",
|
||||||
|
string StatusLabel = "Bereit",
|
||||||
|
string? Elapsed = null,
|
||||||
|
string? Think = null,
|
||||||
|
string? Next = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record MessageEntry(
|
public sealed record MessageEntry(
|
||||||
|
|||||||
+14
-212
@@ -1,224 +1,26 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Nexus.Api.Extensions;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
||||||
using Microsoft.IdentityModel.Tokens;
|
|
||||||
using Nexus.Api.Data;
|
|
||||||
using Nexus.Api.Integrations;
|
|
||||||
using Nexus.Api.Middleware;
|
|
||||||
using Nexus.Api.Repositories;
|
|
||||||
using Nexus.Api.Routing;
|
|
||||||
using Nexus.Api.Services;
|
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// --- JWT Configuration ---
|
// --- Service Registration ---
|
||||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
builder.Services.AddNexusAuth(builder.Configuration);
|
||||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
|
builder.Services.AddNexusRateLimiting();
|
||||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web";
|
builder.Services.AddNexusForwardedHeaders();
|
||||||
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
builder.Services.AddNexusSwagger();
|
||||||
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||||
|
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddNexusApplicationServices();
|
||||||
.AddJwtBearer(options =>
|
builder.Services.AddNexusRepositories();
|
||||||
{
|
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||||
options.MapInboundClaims = false;
|
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
|
||||||
{
|
|
||||||
ValidateIssuer = true,
|
|
||||||
ValidateAudience = true,
|
|
||||||
ValidateLifetime = true,
|
|
||||||
ValidateIssuerSigningKey = true,
|
|
||||||
ValidIssuer = jwtIssuer,
|
|
||||||
ValidAudience = jwtAudience,
|
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
|
||||||
NameClaimType = JwtRegisteredClaimNames.Sub,
|
|
||||||
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
|
||||||
ClockSkew = TimeSpan.FromSeconds(30)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthorization();
|
|
||||||
builder.Services.AddAntiforgery(options =>
|
|
||||||
{
|
|
||||||
options.HeaderName = "X-CSRF-TOKEN";
|
|
||||||
options.Cookie.Name = "nexus-csrf";
|
|
||||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
|
||||||
options.Cookie.HttpOnly = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Rate Limiting ---
|
|
||||||
builder.Services.AddRateLimiter(options =>
|
|
||||||
{
|
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
||||||
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = 5,
|
|
||||||
Window = TimeSpan.FromMinutes(1),
|
|
||||||
QueueLimit = 0,
|
|
||||||
AutoReplenishment = true
|
|
||||||
}));
|
|
||||||
|
|
||||||
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
|
||||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
|
||||||
_ => new FixedWindowRateLimiterOptions
|
|
||||||
{
|
|
||||||
PermitLimit = 30,
|
|
||||||
Window = TimeSpan.FromMinutes(1),
|
|
||||||
QueueLimit = 0,
|
|
||||||
AutoReplenishment = true
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Forwarded Headers ---
|
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|
||||||
{
|
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
|
||||||
options.KnownIPNetworks.Clear();
|
|
||||||
options.KnownProxies.Clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Swagger & JSON ---
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
|
||||||
|
|
||||||
// --- Database ---
|
|
||||||
builder.Services.AddDbContext<NexusDbContext>(options =>
|
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
|
|
||||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
|
||||||
|
|
||||||
// --- HTTP Clients ---
|
|
||||||
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddHttpClient("gateway", client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
|
|
||||||
{
|
|
||||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
|
||||||
?? "http://127.0.0.1:18789");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(120);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Application Services ---
|
|
||||||
builder.Services.AddTransient<ModelRoutingService>();
|
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
||||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
|
||||||
|
|
||||||
// --- Repositories ---
|
|
||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
|
||||||
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
|
||||||
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
|
|
||||||
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
|
|
||||||
|
|
||||||
// --- Health Checks ---
|
|
||||||
builder.Services.AddHealthChecks()
|
|
||||||
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
|
||||||
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
|
||||||
|
|
||||||
// --- Controllers ---
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// --- Database Migration & Owner Seeding ---
|
// --- Database Migration & Seeding ---
|
||||||
await using (var scope = app.Services.CreateAsyncScope())
|
await app.EnsureDatabaseAsync();
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
|
||||||
await db.Database.MigrateAsync();
|
|
||||||
|
|
||||||
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
|
||||||
var ownerPassword = builder.Configuration["Owner:Password"];
|
|
||||||
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
|
|
||||||
var hasUsers = await db.Users.AnyAsync();
|
|
||||||
|
|
||||||
if (!hasUsers)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(ownerEmail))
|
|
||||||
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
|
||||||
|
|
||||||
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
|
||||||
? BuildOwnerDisplayName(ownerEmail)
|
|
||||||
: ownerDisplayName;
|
|
||||||
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
|
||||||
? GenerateTemporaryPassword()
|
|
||||||
: ownerPassword;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
|
|
||||||
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
|
|
||||||
|
|
||||||
db.Users.Add(new NexusUser
|
|
||||||
{
|
|
||||||
Email = ownerEmail,
|
|
||||||
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
|
||||||
DisplayName = initialDisplayName,
|
|
||||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
|
||||||
Role = "owner"
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ownerPassword))
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Middleware Pipeline ---
|
// --- Middleware Pipeline ---
|
||||||
app.UseForwardedHeaders();
|
app.UseNexusPipeline(app.Environment);
|
||||||
app.UseRateLimiter();
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.UseSecurityHeaders();
|
|
||||||
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
static string GenerateTemporaryPassword()
|
|
||||||
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
|
||||||
.TrimEnd('=')
|
|
||||||
.Replace('+', '-')
|
|
||||||
.Replace('/', '_');
|
|
||||||
|
|
||||||
static string BuildOwnerDisplayName(string email)
|
|
||||||
{
|
|
||||||
var localPart = email.Split('@', 2)[0].Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
|
||||||
|
|
||||||
var words = localPart
|
|
||||||
.Replace('.', ' ')
|
|
||||||
.Replace('_', ' ')
|
|
||||||
.Replace('-', ' ')
|
|
||||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
|
||||||
|
|
||||||
var displayName = string.Join(' ', words);
|
|
||||||
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ public interface IUserRepository
|
|||||||
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
|
||||||
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
Task UpdateAsync(NexusUser user, CancellationToken ct = default);
|
||||||
|
|
||||||
// Refresh token operations
|
|
||||||
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
|
||||||
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||||
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
|
||||||
|
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
|
||||||
|
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
|
||||||
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
|
||||||
|
|
||||||
Task SaveChangesAsync(CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
|
||||||
=> db.SaveChangesAsync(ct);
|
=> db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
|
||||||
|
if (token is null || token.RevokedAt is not null) return;
|
||||||
|
|
||||||
|
token.RevokedAt = DateTimeOffset.UtcNow;
|
||||||
|
token.ConcurrencyStamp = Guid.NewGuid();
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var activeTokens = await db.RefreshTokens
|
||||||
|
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (activeTokens.Count == 0) return;
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
foreach (var token in activeTokens)
|
||||||
|
{
|
||||||
|
token.RevokedAt = now;
|
||||||
|
token.ConcurrencyStamp = Guid.NewGuid();
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
|
||||||
@@ -51,9 +78,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
if (oldTokens.Count > 0)
|
if (oldTokens.Count > 0)
|
||||||
|
{
|
||||||
db.RefreshTokens.RemoveRange(oldTokens);
|
db.RefreshTokens.RemoveRange(oldTokens);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SaveChangesAsync(CancellationToken ct = default)
|
|
||||||
=> db.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class AgentConfigService : IAgentConfigService
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
|
||||||
|
};
|
||||||
|
|
||||||
|
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
|
||||||
|
{
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!Directory.Exists(workspacePath))
|
||||||
|
return Array.Empty<AgentConfigFileInfo>();
|
||||||
|
|
||||||
|
return Directory.GetFiles(workspacePath, "*.md")
|
||||||
|
.Select(f => new FileInfo(f))
|
||||||
|
.Where(f => AllowedFiles.Contains(f.Name))
|
||||||
|
.OrderBy(f => f.Name)
|
||||||
|
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(safePath!, ct);
|
||||||
|
var fi = new FileInfo(safePath!);
|
||||||
|
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var workspacePath = $"/mnt/workspace-{agentId}";
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var tempPath = safePath + ".tmp";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(tempPath, content, ct);
|
||||||
|
File.Move(tempPath, safePath!, overwrite: true);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fi = new FileInfo(safePath!);
|
||||||
|
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ public sealed class AuthService : IAuthService
|
|||||||
|
|
||||||
if (token.RevokedAt is not null)
|
if (token.RevokedAt is not null)
|
||||||
{
|
{
|
||||||
await RevokeFamilyAsync(token.FamilyId, ct);
|
await _users.RevokeFamilyAsync(token.FamilyId, ct);
|
||||||
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -84,23 +84,12 @@ public sealed class AuthService : IAuthService
|
|||||||
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
if (string.IsNullOrWhiteSpace(refreshToken)) return;
|
||||||
|
|
||||||
var tokenHash = HashToken(refreshToken);
|
var tokenHash = HashToken(refreshToken);
|
||||||
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct);
|
await _users.RevokeTokenAsync(tokenHash, ct);
|
||||||
if (token is null || token.RevokedAt is not null) return;
|
|
||||||
|
|
||||||
token.RevokedAt = DateTimeOffset.UtcNow;
|
|
||||||
token.ConcurrencyStamp = Guid.NewGuid();
|
|
||||||
await _users.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
|
||||||
=> Task.Run(async () =>
|
=> _users.GetByIdAsync(userId, ct).AsTask();
|
||||||
{
|
|
||||||
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
|
|
||||||
// For read-only access, we call it but the result shouldn't be mutated
|
|
||||||
return await _users.GetByIdAsync(userId, ct);
|
|
||||||
}, ct);
|
|
||||||
|
|
||||||
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -228,19 +217,6 @@ public sealed class AuthService : IAuthService
|
|||||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
|
||||||
foreach (var token in activeTokens)
|
|
||||||
{
|
|
||||||
token.RevokedAt = now;
|
|
||||||
token.ConcurrencyStamp = Guid.NewGuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _users.SaveChangesAsync(ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateRefreshToken()
|
private static string GenerateRefreshToken()
|
||||||
{
|
{
|
||||||
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class CalendarService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger<CalendarService> logger) : ICalendarService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateGatewayClient();
|
||||||
|
var response = await client.GetAsync("/api/cron", ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
|
||||||
|
return data ?? new List<CronJobEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildFallbackCronJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = CreateGatewayClient();
|
||||||
|
var response = await client.GetAsync("/api/cron/upcoming", ct);
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
|
||||||
|
return data ?? new List<UpcomingCronEntry>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return BuildFallbackUpcomingJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpClient CreateGatewayClient()
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient("gateway");
|
||||||
|
var token = configuration["Integrations:OpenClaw:Token"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
|
||||||
|
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
|
||||||
|
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
|
||||||
|
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
|
||||||
|
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
return
|
||||||
|
[
|
||||||
|
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
|
||||||
|
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
|
||||||
|
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
|
||||||
|
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
|
||||||
|
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class DashboardService(
|
||||||
|
IOpenClawGatewayClient gateway,
|
||||||
|
ITaskService taskService,
|
||||||
|
ILogger<DashboardService> logger) : IDashboardService
|
||||||
|
{
|
||||||
|
public async Task<DashboardStatus> GetStatusAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetStatusAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard status check failed");
|
||||||
|
return new DashboardStatus(false, "Offline", 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard agents fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(agentFilter))
|
||||||
|
{
|
||||||
|
entries = entries
|
||||||
|
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard operations fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.SendChatMessageAsync(agentId, message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard chat send failed");
|
||||||
|
return new ChatResponse(false, null, "Gateway nicht erreichbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
|
||||||
|
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
|
||||||
|
return messages
|
||||||
|
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard messages fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cronTask = gateway.GetQueueAsync();
|
||||||
|
var tasksTask = taskService.GetOpenAsync(ct);
|
||||||
|
await Task.WhenAll(cronTask, tasksTask);
|
||||||
|
|
||||||
|
var merged = new List<QueueItem>(cronTask.Result);
|
||||||
|
foreach (var t in tasksTask.Result)
|
||||||
|
{
|
||||||
|
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "Dashboard queue fetch failed");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var ok = await gateway.DeleteCronJobAsync(id);
|
||||||
|
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
|
||||||
|
{
|
||||||
|
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||||
|
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||||
|
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
|
||||||
|
|
||||||
|
var result = await taskService.CompleteViaQueueAsync(guid, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
|
||||||
|
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var deleted = await gateway.DeleteCronJobAsync(id);
|
||||||
|
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!id.StartsWith("task-"))
|
||||||
|
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
|
||||||
|
|
||||||
|
if (!Guid.TryParse(id["task-".Length..], out var guid))
|
||||||
|
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
|
||||||
|
|
||||||
|
var result = await taskService.CyclePriorityAsync(guid, ct);
|
||||||
|
return result.Outcome switch
|
||||||
|
{
|
||||||
|
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
|
||||||
|
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentModelAsync(agentId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetAgentModelAsync(string agentId, string model)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.SetAgentModelAsync(agentId, model);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
|
||||||
|
|
||||||
|
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" or "critical" or "urgent" => "high",
|
||||||
|
"low" or "minor" => "low",
|
||||||
|
_ => "medium"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["high"] = 0, ["medium"] = 1, ["low"] = 2
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class DocService : IDocService
|
||||||
|
{
|
||||||
|
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
|
||||||
|
private static readonly string[] SearchRoots =
|
||||||
|
[
|
||||||
|
"/mnt/workspace-iris",
|
||||||
|
"/home/node/.openclaw/workspace/nexus"
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly (string Dir, string Category)[] ScanDirectories =
|
||||||
|
[
|
||||||
|
("/mnt/workspace-iris/nexus-phases", "phases"),
|
||||||
|
("/mnt/workspace-iris/skills", "skills"),
|
||||||
|
("/mnt/workspace-iris", "workspace"),
|
||||||
|
("/home/node/.openclaw/workspace/nexus", "nexus"),
|
||||||
|
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
|
||||||
|
];
|
||||||
|
|
||||||
|
public IReadOnlyList<DocFileInfo> GetAll()
|
||||||
|
{
|
||||||
|
var results = new List<DocFileInfo>();
|
||||||
|
|
||||||
|
foreach (var (dir, category) in ScanDirectories)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir)) continue;
|
||||||
|
foreach (var file in Directory.GetFiles(dir, "*.*"))
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(file).ToLowerInvariant();
|
||||||
|
if (!AllowedExtensions.Contains(ext)) continue;
|
||||||
|
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
results.Add(new DocFileInfo(
|
||||||
|
fi.Name,
|
||||||
|
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
|
||||||
|
category,
|
||||||
|
ext.Replace(".", ""),
|
||||||
|
fi.Length,
|
||||||
|
fi.LastWriteTimeUtc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DocFileContent?> GetFileAsync(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string? resolvedPath = null;
|
||||||
|
foreach (var root in SearchRoots)
|
||||||
|
{
|
||||||
|
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
|
||||||
|
{
|
||||||
|
resolvedPath = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolvedPath is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(resolvedPath);
|
||||||
|
var fi = new FileInfo(resolvedPath);
|
||||||
|
var relativePath = resolvedPath
|
||||||
|
.Replace("/mnt/workspace-iris/", "")
|
||||||
|
.Replace("/home/node/.openclaw/workspace/nexus/", "");
|
||||||
|
|
||||||
|
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public interface IAgentConfigService
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface ICalendarService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
|
||||||
|
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
|
||||||
|
|
||||||
|
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
|
||||||
|
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
|
||||||
|
|
||||||
|
public interface IDashboardService
|
||||||
|
{
|
||||||
|
Task<DashboardStatus> GetStatusAsync();
|
||||||
|
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||||
|
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
|
||||||
|
Task<ChatResponse> SendChatAsync(string agentId, string message);
|
||||||
|
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
|
||||||
|
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
|
||||||
|
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
|
||||||
|
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
|
||||||
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
|
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||||
|
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
|
||||||
|
List<ModelOption> GetAvailableModels();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record DocFileInfo(
|
||||||
|
string Name,
|
||||||
|
string Path,
|
||||||
|
string Category,
|
||||||
|
string Type,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record DocFileContent(
|
||||||
|
string Name,
|
||||||
|
string Path,
|
||||||
|
string Content,
|
||||||
|
long Size,
|
||||||
|
DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public interface IDocService
|
||||||
|
{
|
||||||
|
IReadOnlyList<DocFileInfo> GetAll();
|
||||||
|
Task<DocFileContent?> GetFileAsync(string path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record IncidentSummary(
|
||||||
|
string Name,
|
||||||
|
string Title,
|
||||||
|
string? Date,
|
||||||
|
string Severity,
|
||||||
|
string Excerpt,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
public sealed record IncidentDetail(
|
||||||
|
string Name,
|
||||||
|
string Title,
|
||||||
|
string? Date,
|
||||||
|
string Content,
|
||||||
|
long Size);
|
||||||
|
|
||||||
|
public interface IIncidentService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
|
||||||
|
Task<IncidentDetail?> GetByNameAsync(string name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
|
||||||
|
|
||||||
|
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
|
||||||
|
|
||||||
|
public interface IMemoryService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
|
||||||
|
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
|
||||||
|
Task<MemoryFileContent?> GetFileAsync(string name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Nexus.Api.Models;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface IOpenClawGatewayClient
|
||||||
|
{
|
||||||
|
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
|
||||||
|
Task<DashboardStatus> GetStatusAsync();
|
||||||
|
Task<List<DashboardAgentInfo>> GetAgentsAsync();
|
||||||
|
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
|
||||||
|
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
|
||||||
|
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
|
||||||
|
Task<List<QueueItem>> GetQueueAsync();
|
||||||
|
Task<bool> DeleteCronJobAsync(string id);
|
||||||
|
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
|
||||||
|
Task<bool> SetAgentModelAsync(string agentId, string model);
|
||||||
|
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
|
||||||
|
List<ModelOption> GetAvailableModels();
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public interface IOperationsService
|
||||||
|
{
|
||||||
|
Task<object> GetSnapshotAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum ProjectDeleteOutcome { NotFound, Deleted, Archived }
|
||||||
|
|
||||||
|
public sealed record ProjectDeleteResult(ProjectDeleteOutcome Outcome, Project? Project = null);
|
||||||
|
|
||||||
|
public interface IProjectService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
|
||||||
|
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
|
||||||
|
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public enum TaskOperationOutcome { Success, NotFound, InvalidState }
|
||||||
|
|
||||||
|
public sealed record TaskOperationResult(TaskOperationOutcome Outcome, WorkTask? Task = null);
|
||||||
|
|
||||||
|
public interface ITaskService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
// Dashboard-facing task operations
|
||||||
|
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
|
||||||
|
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
|
||||||
|
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed record TeamMember(
|
||||||
|
string Id,
|
||||||
|
string Name,
|
||||||
|
string Role,
|
||||||
|
string Model,
|
||||||
|
OperationalStatus Status,
|
||||||
|
DateTimeOffset? LastSeen,
|
||||||
|
string? Workspace,
|
||||||
|
string? Description,
|
||||||
|
string Identity);
|
||||||
|
|
||||||
|
public interface ITeamService
|
||||||
|
{
|
||||||
|
Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed partial class IncidentService : IIncidentService
|
||||||
|
{
|
||||||
|
private const string BasePath = "/mnt/workspace-iris/memory/incidents";
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<IncidentSummary>> GetAllAsync()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(BasePath))
|
||||||
|
return Array.Empty<IncidentSummary>();
|
||||||
|
|
||||||
|
var incidents = new List<IncidentSummary>();
|
||||||
|
foreach (var file in Directory.GetFiles(BasePath, "*.md").OrderByDescending(f => f).Take(50))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
if (fi.Length > 1_000_000) continue;
|
||||||
|
|
||||||
|
var name = Path.GetFileNameWithoutExtension(file);
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
var title = ExtractTitle(name, content);
|
||||||
|
var date = ExtractDate(name);
|
||||||
|
var severity = ExtractSeverity(content);
|
||||||
|
var excerpt = ExtractExcerpt(content);
|
||||||
|
|
||||||
|
incidents.Add(new IncidentSummary(Path.GetFileName(file), title, date, severity, excerpt, fi.Length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return incidents;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IncidentDetail?> GetByNameAsync(string name)
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out var filePath))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
{
|
||||||
|
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
filePath = Path.Combine(BasePath, name + ".md");
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(filePath!);
|
||||||
|
var fi = new FileInfo(filePath!);
|
||||||
|
var fileName = Path.GetFileName(filePath!);
|
||||||
|
var title = ExtractTitle(Path.GetFileNameWithoutExtension(filePath!), content);
|
||||||
|
var date = ExtractDate(fileName);
|
||||||
|
|
||||||
|
return new IncidentDetail(fileName, title, date, content, fi.Length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractTitle(string name, string content)
|
||||||
|
{
|
||||||
|
var match = TitleRegex().Match(content);
|
||||||
|
return match.Success ? match.Groups[1].Value.Trim() : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractDate(string fileName)
|
||||||
|
{
|
||||||
|
var match = DateRegex().Match(fileName);
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractSeverity(string content)
|
||||||
|
{
|
||||||
|
var match = SeverityRegex().Match(content);
|
||||||
|
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractExcerpt(string content)
|
||||||
|
{
|
||||||
|
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
|
||||||
|
var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim();
|
||||||
|
return excerpt.Length > 200 ? excerpt[..200] + "…" : excerpt;
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex TitleRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(\d{4}-\d{2}-\d{2})")]
|
||||||
|
private static partial Regex DateRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline)]
|
||||||
|
private static partial Regex SeverityRegex();
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
using Nexus.Api.Helpers;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class MemoryService : IMemoryService
|
||||||
|
{
|
||||||
|
private const string BasePath = "/mnt/workspace-iris/memory";
|
||||||
|
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
|
||||||
|
private const int MaxFileSize = 1_000_000;
|
||||||
|
private const int MaxFiles = 50;
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
|
||||||
|
{
|
||||||
|
var files = new List<MemoryFileInfo>();
|
||||||
|
|
||||||
|
if (File.Exists(LongTermPath))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(LongTermPath);
|
||||||
|
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Directory.Exists(BasePath))
|
||||||
|
{
|
||||||
|
var memFiles = Directory.GetFiles(BasePath, "*.md")
|
||||||
|
.Select(f => new FileInfo(f))
|
||||||
|
.OrderByDescending(f => f.Name)
|
||||||
|
.Select(f => new MemoryFileInfo(
|
||||||
|
f.Name,
|
||||||
|
f.FullName.Replace(BasePath, "").TrimStart('/'),
|
||||||
|
f.Length,
|
||||||
|
f.LastWriteTimeUtc));
|
||||||
|
files.AddRange(memFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
|
||||||
|
{
|
||||||
|
var results = new List<MemorySearchResult>();
|
||||||
|
|
||||||
|
async Task SearchDir(string dir)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(dir)) return;
|
||||||
|
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
|
||||||
|
{
|
||||||
|
var fi = new FileInfo(file);
|
||||||
|
if (fi.Length > MaxFileSize) continue;
|
||||||
|
var content = await File.ReadAllTextAsync(file);
|
||||||
|
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var start = Math.Max(0, idx - 60);
|
||||||
|
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||||
|
results.Add(new MemorySearchResult(
|
||||||
|
Path.GetFileName(file),
|
||||||
|
file.Replace(BasePath, "").TrimStart('/'),
|
||||||
|
excerpt,
|
||||||
|
fi.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await SearchDir(BasePath);
|
||||||
|
|
||||||
|
if (File.Exists(LongTermPath))
|
||||||
|
{
|
||||||
|
var content = await File.ReadAllTextAsync(LongTermPath);
|
||||||
|
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var start = Math.Max(0, idx - 60);
|
||||||
|
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
|
||||||
|
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MemoryFileContent?> GetFileAsync(string name)
|
||||||
|
{
|
||||||
|
string? filePath;
|
||||||
|
|
||||||
|
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
filePath = LongTermPath;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(filePath!))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(filePath!);
|
||||||
|
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ using Nexus.Api.Models;
|
|||||||
|
|
||||||
namespace Nexus.Api.Services;
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration)
|
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration) : IOpenClawGatewayClient
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -202,7 +202,12 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
Tags: tags,
|
Tags: tags,
|
||||||
Progress: progress,
|
Progress: progress,
|
||||||
Workload: workload,
|
Workload: workload,
|
||||||
Goal: goal
|
Goal: goal,
|
||||||
|
RoleBadge: DeriveRoleBadge(id),
|
||||||
|
StatusLabel: DeriveStatusLabel(isActive, status),
|
||||||
|
Elapsed: FormatElapsed(status),
|
||||||
|
Think: null,
|
||||||
|
Next: DeriveNext(isActive, currentTask)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return agents;
|
return agents;
|
||||||
@@ -415,7 +420,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
if (toolResult is null)
|
if (toolResult is null)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o")));
|
var json = toolResult.ToJsonString();
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
|
|
||||||
@@ -840,8 +845,8 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
|
|
||||||
// 3. Look for "## Oberstes Prinzip" as second choice
|
// 3. Look for "## Oberstes Prinzip" as second choice
|
||||||
inRoleSection = false;
|
inRoleSection = false;
|
||||||
reader = new StringReader(soul);
|
using var reader2 = new StringReader(soul);
|
||||||
while ((line = reader.ReadLine()) is not null)
|
while ((line = reader2.ReadLine()) is not null)
|
||||||
{
|
{
|
||||||
var trimmed = line.Trim();
|
var trimmed = line.Trim();
|
||||||
if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0)
|
if (trimmed.StartsWith("## ") && trimmed.IndexOf("Prinzip", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||||
@@ -1060,6 +1065,50 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
return slash > 0 ? modelId[..slash] : "unknown";
|
return slash > 0 ? modelId[..slash] : "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string DeriveRoleBadge(string agentId) => agentId.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"iris" => "badge-purple",
|
||||||
|
"programmer" or "developer" => "badge-blue",
|
||||||
|
"reviewer" => "badge-amber",
|
||||||
|
"architekt" => "badge-cyan",
|
||||||
|
"executor" => "badge-rose",
|
||||||
|
"researcher" => "badge-green",
|
||||||
|
_ => "badge-slate"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string DeriveStatusLabel(bool isActive, JsonNode? status)
|
||||||
|
{
|
||||||
|
if (!isActive) return "Bereit";
|
||||||
|
var statusText = status?["status"]?.GetValue<string>()?.ToLowerInvariant();
|
||||||
|
return statusText switch
|
||||||
|
{
|
||||||
|
"thinking" or "think" => "Plant",
|
||||||
|
"blocked" or "block" => "Blockiert",
|
||||||
|
_ => "Arbeitet"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FormatElapsed(JsonNode? status)
|
||||||
|
{
|
||||||
|
var lastActivity = status?["lastActivity"]?.GetValue<string>()
|
||||||
|
?? status?["lastMessage"]?.GetValue<string>();
|
||||||
|
if (lastActivity is null) return null;
|
||||||
|
if (!DateTimeOffset.TryParse(lastActivity, out var ts)) return null;
|
||||||
|
var diff = DateTimeOffset.UtcNow - ts;
|
||||||
|
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";
|
||||||
|
return $"{(int)diff.TotalDays}d";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DeriveNext(bool isActive, string? currentTask)
|
||||||
|
{
|
||||||
|
if (!isActive) return "Standby";
|
||||||
|
if (!string.IsNullOrWhiteSpace(currentTask) && currentTask != "Working...")
|
||||||
|
return currentTask.Length > 60 ? currentTask[..60] + "…" : currentTask;
|
||||||
|
return "Aufgabe ausführen";
|
||||||
|
}
|
||||||
|
|
||||||
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
"iris" => "Chief of Staff",
|
"iris" => "Chief of Staff",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.Integrations;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class OperationsService(
|
||||||
|
IAgentRuntime runtime,
|
||||||
|
IAgentService agentService,
|
||||||
|
IProjectRepository projectRepo,
|
||||||
|
ITaskRepository taskRepo,
|
||||||
|
IActivityRepository activityRepo) : IOperationsService
|
||||||
|
{
|
||||||
|
public async Task<object> GetSnapshotAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var runtimeTask = runtime.GetStatusAsync(ct);
|
||||||
|
var agentsTask = agentService.GetAgentsAsync(ct);
|
||||||
|
// Repository calls share the scoped EF Core DbContext and must stay serialized.
|
||||||
|
var projects = await projectRepo.GetAllAsync(ct);
|
||||||
|
var tasks = await taskRepo.GetAllAsync(ct);
|
||||||
|
var activity = await activityRepo.GetRecentAsync(20, ct);
|
||||||
|
var agents = await agentsTask;
|
||||||
|
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||||
|
var runtimeStatus = await runtimeTask;
|
||||||
|
|
||||||
|
var lastIncident = tasks
|
||||||
|
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||||
|
.OrderByDescending(x => x.UpdatedAt)
|
||||||
|
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
generatedAt = DateTimeOffset.UtcNow,
|
||||||
|
runtime = runtimeStatus,
|
||||||
|
models = Array.Empty<object>(),
|
||||||
|
runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online,
|
||||||
|
metrics = new
|
||||||
|
{
|
||||||
|
activeAgents = agents.Count,
|
||||||
|
queuedTasks = tasks.Count - completedTasks,
|
||||||
|
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
|
||||||
|
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||||
|
},
|
||||||
|
lastIncident,
|
||||||
|
projectHealth = new
|
||||||
|
{
|
||||||
|
Online = projects.Count(x => x.Status == OperationalStatus.Online),
|
||||||
|
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
|
||||||
|
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
|
||||||
|
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
|
||||||
|
},
|
||||||
|
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
||||||
|
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
|
||||||
|
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
|
||||||
|
activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class ProjectService(
|
||||||
|
IProjectRepository projectRepo,
|
||||||
|
IActivityRepository activityRepo) : IProjectService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await projectRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
=> await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = new Project
|
||||||
|
{
|
||||||
|
Name = request.Name.Trim(),
|
||||||
|
Description = request.Description?.Trim() ?? string.Empty,
|
||||||
|
Status = OperationalStatus.Online
|
||||||
|
};
|
||||||
|
await projectRepo.AddAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
if (project is null) return null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
project.Name = request.Name.Trim();
|
||||||
|
if (request.Description is not null)
|
||||||
|
project.Description = request.Description.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
|
||||||
|
project.Status = parsedStatus;
|
||||||
|
|
||||||
|
await projectRepo.UpdateAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var project = await projectRepo.GetByIdAsync(id, ct);
|
||||||
|
if (project is null) return new ProjectDeleteResult(ProjectDeleteOutcome.NotFound);
|
||||||
|
|
||||||
|
if (await projectRepo.HasTasksAsync(id, ct))
|
||||||
|
{
|
||||||
|
project.Status = OperationalStatus.Offline;
|
||||||
|
await projectRepo.UpdateAsync(project, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
|
||||||
|
return new ProjectDeleteResult(ProjectDeleteOutcome.Archived, project);
|
||||||
|
}
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
|
||||||
|
await projectRepo.DeleteAsync(project, ct);
|
||||||
|
return new ProjectDeleteResult(ProjectDeleteOutcome.Deleted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
using Nexus.Api.Data;
|
||||||
|
using Nexus.Api.DTOs;
|
||||||
|
using Nexus.Api.Repositories;
|
||||||
|
|
||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class TaskService(
|
||||||
|
ITaskRepository taskRepo,
|
||||||
|
IActivityRepository activityRepo) : ITaskService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetAllAsync(ct);
|
||||||
|
|
||||||
|
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
|
||||||
|
=> await taskRepo.GetPendingApprovalAsync(ct);
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = new WorkTask
|
||||||
|
{
|
||||||
|
Title = request.Title.Trim(),
|
||||||
|
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
|
||||||
|
ProjectId = request.ProjectId
|
||||||
|
};
|
||||||
|
await taskRepo.AddAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.State = TaskStateHelper.ToStateString(TaskState.Done);
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.State = canonical;
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||||
|
task.Title = request.Title.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Priority))
|
||||||
|
task.Priority = request.Priority.Trim();
|
||||||
|
if (request.ProjectId.HasValue)
|
||||||
|
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||||
|
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
|
||||||
|
await taskRepo.DeleteAsync(task, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard-facing operations ──
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await taskRepo.GetAllAsync(ct);
|
||||||
|
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.OrderByDescending(t => t.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkTask> CreateDashboardTaskAsync(
|
||||||
|
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = new WorkTask
|
||||||
|
{
|
||||||
|
Title = title.Trim(),
|
||||||
|
Detail = detail?.Trim(),
|
||||||
|
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
|
||||||
|
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
|
||||||
|
AssignedTo = assignedTo?.Trim()
|
||||||
|
};
|
||||||
|
await taskRepo.AddAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
|
||||||
|
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
|
||||||
|
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
|
||||||
|
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (!TaskStateHelper.IsValidState(status))
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||||
|
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
|
||||||
|
task.State = canonical;
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.State = "Done";
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||||
|
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||||
|
|
||||||
|
task.Priority = task.Priority.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"high" => "Medium",
|
||||||
|
"medium" => "Low",
|
||||||
|
"low" => "High",
|
||||||
|
_ => "Medium"
|
||||||
|
};
|
||||||
|
|
||||||
|
await taskRepo.UpdateAsync(task, ct);
|
||||||
|
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
|
||||||
|
return new TaskOperationResult(TaskOperationOutcome.Success, task);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
namespace Nexus.Api.Services;
|
||||||
|
|
||||||
|
public sealed class TeamService(IAgentService agentService) : ITeamService
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var agents = await agentService.GetAgentsAsync(ct);
|
||||||
|
var team = new List<TeamMember>(agents.Count);
|
||||||
|
|
||||||
|
foreach (var agent in agents)
|
||||||
|
{
|
||||||
|
var identity = await ReadIdentityAsync(agent.Workspace, ct);
|
||||||
|
team.Add(new TeamMember(
|
||||||
|
agent.Id, agent.Name, agent.Role, agent.Model,
|
||||||
|
agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
|
||||||
|
identity));
|
||||||
|
}
|
||||||
|
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadIdentityAsync(string? workspace, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var identityFile = Path.Combine(workspace, "IDENTITY.md");
|
||||||
|
if (!File.Exists(identityFile))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
var content = await File.ReadAllTextAsync(identityFile, ct);
|
||||||
|
return string.Join("\n", content.Split('\n').Where(l => l.StartsWith("- **")).Take(8));
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -4,6 +4,12 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
reservations:
|
||||||
|
memory: 64M
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-nexus}
|
POSTGRES_DB: ${POSTGRES_DB:-nexus}
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-nexus}
|
POSTGRES_USER: ${POSTGRES_USER:-nexus}
|
||||||
@@ -28,6 +34,11 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 512M
|
||||||
|
reservations:
|
||||||
|
memory: 128M
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
delay: 5s
|
delay: 5s
|
||||||
@@ -53,7 +64,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -80,6 +91,11 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 128M
|
||||||
|
reservations:
|
||||||
|
memory: 32M
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
delay: 5s
|
delay: 5s
|
||||||
@@ -87,9 +103,11 @@ services:
|
|||||||
window: 120s
|
window: 120s
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:18880:80"
|
- "127.0.0.1:18880:80"
|
||||||
depends_on: [api]
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,11 @@ defineEmits<{
|
|||||||
{ entering }
|
{ entering }
|
||||||
]"
|
]"
|
||||||
:style="{ left: left + '%', top: top + '%' }"
|
:style="{ left: left + '%', top: top + '%' }"
|
||||||
@click="$emit('select', agent.id)"
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
:aria-label="`${agent.name} öffnen`"
|
||||||
|
@keydown.enter.prevent="$emit('select', agent.id)"
|
||||||
|
@keydown.space.prevent="$emit('select', agent.id)"
|
||||||
>
|
>
|
||||||
<div class="ncard">
|
<div class="ncard">
|
||||||
<!-- Header: Avatar + Name + Role + Status-Dot -->
|
<!-- Header: Avatar + Name + Role + Status-Dot -->
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ defineProps<{
|
|||||||
blockerCount: number
|
blockerCount: number
|
||||||
todayCost: string
|
todayCost: string
|
||||||
todayTokens: string
|
todayTokens: string
|
||||||
|
blockerLabel?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
@@ -62,7 +63,7 @@ defineEmits<{
|
|||||||
@click="$emit('blockerClick')"
|
@click="$emit('blockerClick')"
|
||||||
>
|
>
|
||||||
<span class="dot block"></span>
|
<span class="dot block"></span>
|
||||||
{{ blockerCount }} Blocker
|
{{ blockerLabel || `${blockerCount} Blocker` }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -168,4 +169,24 @@ defineEmits<{
|
|||||||
.blk:hover {
|
.blk:hover {
|
||||||
background: rgba(251,113,133,.22);
|
background: rgba(251,113,133,.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.alertbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
flex: 0 0 calc(50% - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blk {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { AgentNodeData } from '../../../composables/useFlowLayout'
|
|||||||
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
|
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
|
||||||
import { icons } from '../../../composables/icons'
|
import { icons } from '../../../composables/icons'
|
||||||
import AgentNode from './AgentNode.vue'
|
import AgentNode from './AgentNode.vue'
|
||||||
|
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
agents: AgentNodeData[]
|
agents: AgentNodeData[]
|
||||||
@@ -113,8 +114,8 @@ function renderEdges() {
|
|||||||
} else {
|
} else {
|
||||||
// Orchestration (Iris → Agent)
|
// Orchestration (Iris → Agent)
|
||||||
const targetAgent = props.agents.find(a => a.id === e.b)
|
const targetAgent = props.agents.find(a => a.id === e.b)
|
||||||
const op = targetAgent && isActive(targetAgent.status) ? 0.45 : 0.18
|
const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
|
||||||
paths += `<path d="${d}" fill="none" stroke="#7c6cff" stroke-width="1.2" stroke-dasharray="2 6" opacity="${op}"/>`
|
paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -172,93 +173,19 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/* ── Drag & Drop ──────────────────────────────── */
|
/* ── Drag & Drop ──────────────────────────────── */
|
||||||
const DRAG_THRESHOLD = 5
|
const {
|
||||||
|
onClick,
|
||||||
interface DragState {
|
onClickCapture,
|
||||||
id: string
|
onPointerDown,
|
||||||
startX: number
|
onPointerMove,
|
||||||
startY: number
|
onPointerUp,
|
||||||
ox: number
|
} = useFlowCanvasInteractions({
|
||||||
oy: number
|
flowRef,
|
||||||
moved: boolean
|
renderEdges,
|
||||||
raf: number | null
|
updatePositions: positions => emit('updatePositions', positions),
|
||||||
}
|
selectAgent: id => emit('select', id),
|
||||||
|
getPositions: () => props.positions,
|
||||||
let drag: DragState | null = null
|
})
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
|
||||||
const node = (e.target as HTMLElement).closest('.node') as HTMLElement | null
|
|
||||||
if (!node) return
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
const nr = node.getBoundingClientRect()
|
|
||||||
|
|
||||||
drag = {
|
|
||||||
id: node.dataset.id || '',
|
|
||||||
startX: e.clientX,
|
|
||||||
startY: e.clientY,
|
|
||||||
ox: e.clientX - (nr.left + nr.width / 2),
|
|
||||||
oy: e.clientY - (nr.top + nr.height / 2),
|
|
||||||
moved: false,
|
|
||||||
raf: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
node.setPointerCapture(e.pointerId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerMove(e: PointerEvent) {
|
|
||||||
if (!drag) return
|
|
||||||
|
|
||||||
const dist = Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY)
|
|
||||||
if (!drag.moved && dist < DRAG_THRESHOLD) return
|
|
||||||
|
|
||||||
if (!drag.moved) {
|
|
||||||
drag.moved = true
|
|
||||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
|
||||||
if (node) node.classList.add('dragging')
|
|
||||||
}
|
|
||||||
|
|
||||||
const flow = flowRef.value
|
|
||||||
if (!flow) return
|
|
||||||
|
|
||||||
const fr = flow.getBoundingClientRect()
|
|
||||||
const x = Math.max(8, Math.min(92, ((e.clientX - drag.ox - fr.left) / fr.width) * 100))
|
|
||||||
const y = Math.max(10, Math.min(92, ((e.clientY - drag.oy - fr.top) / fr.height) * 100))
|
|
||||||
|
|
||||||
// Direct DOM manipulation for responsiveness
|
|
||||||
const node = flow.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
|
||||||
if (node) {
|
|
||||||
node.style.left = x + '%'
|
|
||||||
node.style.top = y + '%'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update positions state
|
|
||||||
const newPos = { ...props.positions }
|
|
||||||
newPos[drag.id] = { x, y }
|
|
||||||
emit('updatePositions', newPos)
|
|
||||||
|
|
||||||
// Debounced edge re-render
|
|
||||||
if (!drag.raf) {
|
|
||||||
drag.raf = requestAnimationFrame(() => {
|
|
||||||
renderEdges()
|
|
||||||
if (drag) drag.raf = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onPointerUp() {
|
|
||||||
if (!drag) return
|
|
||||||
|
|
||||||
const node = flowRef.value?.querySelector(`.node[data-id="${drag.id}"]`) as HTMLElement | null
|
|
||||||
if (node) node.classList.remove('dragging')
|
|
||||||
|
|
||||||
if (!drag.moved) {
|
|
||||||
// Was a click — emit select
|
|
||||||
emit('select', drag.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
drag = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Keyboard handler for Enter key on buttons ── */
|
/* ── Keyboard handler for Enter key on buttons ── */
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
@@ -271,6 +198,8 @@ function handleReset() {
|
|||||||
<div
|
<div
|
||||||
ref="flowRef"
|
ref="flowRef"
|
||||||
class="flow"
|
class="flow"
|
||||||
|
@click="onClick"
|
||||||
|
@click.capture="onClickCapture"
|
||||||
@pointerdown="onPointerDown"
|
@pointerdown="onPointerDown"
|
||||||
@pointermove="onPointerMove"
|
@pointermove="onPointerMove"
|
||||||
@pointerup="onPointerUp"
|
@pointerup="onPointerUp"
|
||||||
@@ -288,12 +217,12 @@ function handleReset() {
|
|||||||
@click="handleReset"
|
@click="handleReset"
|
||||||
>
|
>
|
||||||
<span class="btn-icon" v-html="icons.flow || ''"></span>
|
<span class="btn-icon" v-html="icons.flow || ''"></span>
|
||||||
Reset
|
<span class="reset-label">Reset</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="add-btn" @click="emit('add')">
|
<button class="add-btn" @click="emit('add')" title="Agent hinzufügen">
|
||||||
<span class="btn-icon" v-html="icons.plus || ''"></span>
|
<span class="btn-icon" v-html="icons.plus || ''"></span>
|
||||||
Agent hinzufügen
|
<span class="add-label">Agent hinzufügen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -481,4 +410,28 @@ function handleReset() {
|
|||||||
:deep(.node.dragging) {
|
:deep(.node.dragging) {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.add-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
width: 34px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-btn {
|
||||||
|
width: 30px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
import { ref, nextTick, watch } from 'vue'
|
||||||
* IrisChat — Rechte Seitenleiste (Rail) im V2 Dashboard
|
|
||||||
*
|
|
||||||
* Container: 368px breit, border-left 1px var(--line), flex column
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* messages – ChatMessage[]
|
|
||||||
* isThinking – zeigt "thinking…" Indicator an
|
|
||||||
*
|
|
||||||
* Emits:
|
|
||||||
* send(text) – Nachricht absenden
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ref, computed, nextTick, watch } from 'vue'
|
|
||||||
import { icons } from '../../../composables/icons'
|
import { icons } from '../../../composables/icons'
|
||||||
import type { ChatMessage } from './types'
|
import type { ChatMessage } from './types'
|
||||||
|
|
||||||
@@ -26,10 +13,8 @@ const emit = defineEmits<{
|
|||||||
send: [text: string]
|
send: [text: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/* ── Input ────────────────────────────────────────── */
|
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const msgContainer = ref<HTMLElement | null>(null)
|
const scrollEl = ref<HTMLElement | null>(null)
|
||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
|
||||||
|
|
||||||
function handleSend() {
|
function handleSend() {
|
||||||
const text = inputText.value.trim()
|
const text = inputText.value.trim()
|
||||||
@@ -45,439 +30,289 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reversed messages (newest first in DOM for column-reverse) ── */
|
|
||||||
const reversedMessages = computed(() => [...props.messages].reverse())
|
|
||||||
|
|
||||||
/* ── Auto-scroll: column-reverse means scrollTop=0 = bottom (newest) ── */
|
|
||||||
watch(
|
watch(
|
||||||
() => props.messages.length,
|
() => props.messages.length,
|
||||||
() => {
|
() => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (msgContainer.value) {
|
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||||
msgContainer.value.scrollTop = 0
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="irischat">
|
<section class="iris-panel">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="chat-header">
|
<div class="iris-head">
|
||||||
<div class="chat-header-left">
|
<div class="iris-av" v-html="icons.bot || ''"></div>
|
||||||
<span class="header-icon" v-html="icons.bot || ''"></span>
|
<div>
|
||||||
<div class="header-text">
|
<div class="iris-name">Iris</div>
|
||||||
<span class="header-title">Live-Orchestrierung</span>
|
<div class="iris-sub">Chief of Staff · <span class="online">online</span></div>
|
||||||
<span class="header-subtitle">Iris Chat</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button class="expand-btn" type="button" v-html="icons.expand || ''"></button>
|
||||||
<button class="ask-btn" type="button" @click="inputRef?.focus()">
|
|
||||||
<span class="ask-icon" v-html="icons.spark || ''"></span>
|
|
||||||
Ask Iris
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages (flex column-reverse — neueste unten) -->
|
<!-- Chat Scroll -->
|
||||||
<div ref="msgContainer" class="messages">
|
<div ref="scrollEl" class="chat-scroll">
|
||||||
<!-- Error Banner -->
|
<div v-if="error" class="chat-msg-info error">⚠ {{ error }}</div>
|
||||||
<div v-if="error" class="chat-error">
|
<div v-else-if="!messages.length && !isThinking" class="chat-msg-info">Noch keine Nachrichten.</div>
|
||||||
<span class="error-icon">⚠</span>
|
|
||||||
<span>Chat unavailable: {{ error }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Thinking Indicator -->
|
<div v-for="(msg, i) in messages" :key="i" class="chat-row">
|
||||||
<div v-if="isThinking" class="thinking-indicator">
|
<template v-if="msg.sender === 'iris'">
|
||||||
<span class="thinking-dots">
|
<div class="bubble iris">{{ msg.text }}</div>
|
||||||
<span class="dot-1">●</span>
|
<div v-if="msg.tool" class="tool">
|
||||||
<span class="dot-2">●</span>
|
<span v-html="icons.doc || ''"></span>{{ msg.tool }}
|
||||||
<span class="dot-3">●</span>
|
|
||||||
</span>
|
|
||||||
<span class="thinking-text">thinking…</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-if="!messages.length && !isThinking" class="chat-empty">
|
|
||||||
<span class="empty-text">No messages yet. Ask Iris something.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Messages (reverse order → newest first in DOM, column-reverse flips) -->
|
|
||||||
<template v-for="(msg, i) in reversedMessages" :key="i">
|
|
||||||
<!-- Iris Bubble -->
|
|
||||||
<div v-if="msg.sender === 'iris'" class="bubble iris-bubble">
|
|
||||||
<div class="bubble-text">{{ msg.text }}</div>
|
|
||||||
<!-- Tool-Call-Indikator -->
|
|
||||||
<div v-if="msg.tool" class="tool-indicator">
|
|
||||||
<span class="tool-icon" v-html="icons.search || ''"></span>
|
|
||||||
<span class="tool-label">{{ msg.tool }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="bubble-meta">{{ msg.ts }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- User Bubble -->
|
|
||||||
<div v-else class="bubble user-bubble">
|
|
||||||
<div class="bubble-text">{{ msg.text }}</div>
|
|
||||||
<div class="bubble-meta">{{ msg.ts }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<div v-else class="bubble me">{{ msg.text }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input Area -->
|
<div v-if="isThinking" class="chat-row">
|
||||||
<div class="chat-input-area">
|
<div class="bubble iris"><span class="caret"></span></div>
|
||||||
<div class="input-wrap">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="chat-in">
|
||||||
<input
|
<input
|
||||||
ref="inputRef"
|
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
class="chat-input"
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Nachricht an Iris…"
|
placeholder="Nachricht an Iris…"
|
||||||
@keydown="onKeydown"
|
@keydown="onKeydown"
|
||||||
/>
|
/>
|
||||||
<button
|
<button class="send" type="button" @click="handleSend" v-html="icons.send || ''"></button>
|
||||||
class="send-btn"
|
|
||||||
type="button"
|
|
||||||
:disabled="!inputText.trim()"
|
|
||||||
@click="handleSend"
|
|
||||||
:aria-label="'Send message'"
|
|
||||||
>
|
|
||||||
<span v-html="icons.send || ''"></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.irischat {
|
.iris-panel {
|
||||||
width: 368px;
|
width: var(--rail-w, 360px);
|
||||||
flex: 0 0 368px;
|
flex: 0 0 var(--rail-w, 360px);
|
||||||
align-self: stretch;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-left: 1px solid var(--line);
|
min-height: 0;
|
||||||
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
background: linear-gradient(180deg, rgba(20,17,48,.6), rgba(12,10,30,.6));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ───────────────────────────────────────── */
|
/* ── Header ─────────────────────────────────── */
|
||||||
.chat-header {
|
.iris-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 10px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header-left {
|
.iris-av {
|
||||||
display: flex;
|
width: 34px;
|
||||||
align-items: center;
|
height: 34px;
|
||||||
gap: 10px;
|
border-radius: 10px;
|
||||||
|
background: var(--grad);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icon :deep(svg) {
|
.iris-av :deep(svg) {
|
||||||
width: 20px;
|
width: 18px;
|
||||||
height: 20px;
|
height: 18px;
|
||||||
color: var(--a-mid);
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-text {
|
.iris-name {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14.5px;
|
font-size: 14.5px;
|
||||||
color: var(--tx);
|
color: var(--tx);
|
||||||
line-height: 1.3;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-subtitle {
|
.iris-sub {
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--tx-3);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ask-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
height: 29px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: var(--grad);
|
|
||||||
color: #fff;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter 0.15s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ask-btn:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ask-icon :deep(svg) {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Messages ─────────────────────────────────────── */
|
|
||||||
.messages {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px;
|
|
||||||
gap: 10px;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(124, 108, 255, 0.22);
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(124, 108, 255, 0.4);
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Bubbles ──────────────────────────────────────── */
|
|
||||||
.bubble {
|
|
||||||
padding: 10px 13px;
|
|
||||||
max-width: 86%;
|
|
||||||
animation: bubble-in 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bubble-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(6px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.iris-bubble {
|
|
||||||
align-self: flex-start;
|
|
||||||
background: rgba(124, 108, 255, 0.14);
|
|
||||||
border-left: 2px solid var(--a-mid);
|
|
||||||
border-radius: 0 10px 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-bubble {
|
|
||||||
align-self: flex-end;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-right: 2px solid var(--tx-3);
|
|
||||||
border-radius: 10px 0 10px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-text {
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: var(--tx);
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-meta {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--tx-3);
|
|
||||||
margin-top: 4px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Tool-Call-Indikator ──────────────────────────── */
|
|
||||||
.tool-indicator {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 6px;
|
|
||||||
padding: 3px 9px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(52, 214, 245, 0.10);
|
|
||||||
border: 1px solid rgba(52, 214, 245, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-icon :deep(svg) {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
color: var(--st-think);
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tool-label {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--st-think);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Error Banner ─────────────────────────────────── */
|
|
||||||
.chat-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 13px;
|
|
||||||
background: rgba(251, 113, 133, 0.12);
|
|
||||||
border: 1px solid rgba(251, 113, 133, 0.25);
|
|
||||||
border-radius: 10px;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #fda4b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Empty State ──────────────────────────────────── */
|
|
||||||
.chat-empty {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 32px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-empty .empty-text {
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--tx-3);
|
color: var(--tx-3);
|
||||||
font-style: italic;
|
margin-top: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Thinking Indicator ────────────────────────────── */
|
.online {
|
||||||
.thinking-indicator {
|
color: var(--st-work);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking-dots {
|
.expand-btn {
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
gap: 2px;
|
width: 34px;
|
||||||
font-size: 6px;
|
height: 34px;
|
||||||
color: var(--a-mid);
|
border-radius: 9px;
|
||||||
}
|
border: none;
|
||||||
|
|
||||||
.thinking-dots span {
|
|
||||||
animation: think-pop 1.2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-dots .dot-2 {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-dots .dot-3 {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes think-pop {
|
|
||||||
0%, 80%, 100% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: scale(0.7);
|
|
||||||
}
|
|
||||||
40% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.thinking-text {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--tx-3);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Input Area ───────────────────────────────────── */
|
|
||||||
.chat-input-area {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: 10px 12px 12px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: 44px;
|
|
||||||
padding: 0 8px 0 13px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrap:focus-within {
|
|
||||||
border-color: var(--line-3);
|
|
||||||
box-shadow: 0 0 0 3px rgba(124, 108, 255, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input {
|
|
||||||
flex: 1;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
color: var(--tx-2);
|
||||||
outline: none;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--tx);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-input::placeholder {
|
|
||||||
color: var(--tx-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn {
|
|
||||||
flex: 0 0 32px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--grad);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: filter 0.15s, opacity 0.15s;
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-btn:disabled {
|
.expand-btn:hover {
|
||||||
opacity: 0.35;
|
background: rgba(124,108,255,.10);
|
||||||
cursor: default;
|
color: var(--tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-btn:not(:disabled):hover {
|
.expand-btn :deep(svg) {
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-btn :deep(svg) {
|
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Messages ────────────────────────────────── */
|
||||||
|
.chat-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll::-webkit-scrollbar { width: 6px; }
|
||||||
|
.chat-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(124,108,255,.22);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.chat-scroll::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
.chat-msg-info {
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-info.error { color: #fda4b0; font-style: normal; }
|
||||||
|
|
||||||
|
.chat-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 84%;
|
||||||
|
padding: 10px 13px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.iris {
|
||||||
|
background: rgba(124,108,255,.12);
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-bottom-left-radius: 5px;
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.me {
|
||||||
|
background: var(--grad);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 5px;
|
||||||
|
margin-left: auto;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--st-think);
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool :deep(svg) {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret::after {
|
||||||
|
content: '▍';
|
||||||
|
animation: blink 1s steps(1) infinite;
|
||||||
|
color: var(--st-think);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.iris-panel {
|
||||||
|
width: 100%;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-height: 45vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll {
|
||||||
|
max-height: 30vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-btn {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Input ───────────────────────────────────── */
|
||||||
|
.chat-in {
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-in input {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
color: var(--tx);
|
||||||
|
padding: 0 14px;
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-in input::placeholder { color: var(--tx-3); }
|
||||||
|
.chat-in input:focus { border-color: var(--line-3); }
|
||||||
|
|
||||||
|
.send {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 11px;
|
||||||
|
border: none;
|
||||||
|
background: var(--grad);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: filter .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.send :deep(svg) {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
|
||||||
* TaskStrip — Untere Leiste im V2 Dashboard Stage
|
|
||||||
*
|
|
||||||
* Props:
|
|
||||||
* tasks – TaskItem[]
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { TaskItem } from './types'
|
import type { TaskItem } from './types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -13,184 +6,130 @@ defineProps<{
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
error?: string | null
|
error?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
function prioLabel(p: TaskItem['priority']): string {
|
||||||
|
return p === 'high' ? 'P0' : p === 'medium' ? 'P1' : 'P2'
|
||||||
|
}
|
||||||
|
|
||||||
|
function prioColor(p: TaskItem['priority']): string {
|
||||||
|
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
|
||||||
|
}
|
||||||
|
|
||||||
|
function dotClass(s: TaskItem['status']): string {
|
||||||
|
return s === 'active' ? 'work' : s === 'blocked' ? 'block' : 'queue'
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(s: TaskItem['status']): string {
|
||||||
|
return s === 'active' ? 'Läuft' : s === 'blocked' ? 'Blocker' : 'Queue'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="taskstrip v2-scroll">
|
<div class="tstrip">
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<div v-for="n in 3" :key="'sk-' + n" class="taskcard skeleton" />
|
<div v-for="n in 4" :key="n" class="tcard skeleton"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Error -->
|
<div v-else-if="error" class="tstrip-msg">⚠ {{ error }}</div>
|
||||||
<div v-else-if="error" class="task-error">
|
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
|
||||||
<span class="error-icon">⚠</span> {{ error }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty -->
|
<template v-else>
|
||||||
<div v-else-if="!tasks.length" class="task-empty">
|
|
||||||
No active tasks
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tasks -->
|
|
||||||
<div
|
<div
|
||||||
v-for="task in tasks"
|
v-for="task in tasks.slice(0, 4)"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="taskcard"
|
class="tcard"
|
||||||
:class="`task-${task.status}`"
|
:class="{ block: task.status === 'blocked' }"
|
||||||
>
|
>
|
||||||
<!-- Priority Badge -->
|
<div class="tcard-row">
|
||||||
<span class="prio-badge" :class="`prio-${task.priority}`">
|
<span class="pr" :style="{ background: 'rgba(124,108,255,.14)', color: prioColor(task.priority) }">
|
||||||
{{ task.priority === 'high' ? 'P0' : task.priority === 'medium' ? 'P1' : 'P2' }}
|
{{ prioLabel(task.priority) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="dot" :class="dotClass(task.status)"></span>
|
||||||
<!-- Title -->
|
<span class="stl">{{ statusLabel(task.status) }}</span>
|
||||||
<div class="task-title">{{ task.title }}</div>
|
|
||||||
|
|
||||||
<!-- Agent -->
|
|
||||||
<div class="task-agent">{{ task.agent }}</div>
|
|
||||||
|
|
||||||
<!-- Progress Bar -->
|
|
||||||
<div class="task-progress">
|
|
||||||
<div class="bar-track">
|
|
||||||
<div
|
|
||||||
class="bar-fill"
|
|
||||||
:style="{ width: task.progress + '%' }"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tt">{{ task.title }}</div>
|
||||||
|
<div class="ow">{{ task.agent }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.taskstrip {
|
.tstrip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 0 16px 14px;
|
overflow: hidden;
|
||||||
overflow-x: auto;
|
|
||||||
min-height: 0;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Task Card ────────────────────────────────────── */
|
.tcard {
|
||||||
.taskcard {
|
flex: 1;
|
||||||
min-width: 196px;
|
min-width: 0;
|
||||||
max-width: 220px;
|
padding: 11px 13px;
|
||||||
flex: 0 0 auto;
|
border-radius: 12px;
|
||||||
background: var(--glass);
|
background: var(--glass);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
|
||||||
padding: 12px 13px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
|
||||||
position: relative;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Status Variants ──────────────────────────────── */
|
.tcard.block {
|
||||||
.task-active {
|
border-color: rgba(251,113,133,.35);
|
||||||
border-left: 2px solid var(--st-work);
|
background: rgba(251,113,133,.07);
|
||||||
background: rgba(61, 220, 151, 0.04);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-pending {
|
.tcard-row {
|
||||||
border-left: 2px solid var(--st-think);
|
display: flex;
|
||||||
background: rgba(52, 214, 245, 0.04);
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-blocked {
|
.pr {
|
||||||
border-left: 2px solid var(--st-block);
|
|
||||||
background: rgba(255, 106, 106, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Priority Badge ───────────────────────────────── */
|
|
||||||
.prio-badge {
|
|
||||||
display: inline-block;
|
|
||||||
align-self: flex-start;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 1px 7px;
|
padding: 1px 6px;
|
||||||
border-radius: 20px;
|
border-radius: 5px;
|
||||||
line-height: 1.5;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prio-high {
|
.dot {
|
||||||
background: rgba(255, 106, 106, 0.18);
|
width: 8px;
|
||||||
color: var(--st-block);
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prio-medium {
|
.dot.work { background: var(--st-work); animation: pulse-work 1.8s infinite; }
|
||||||
background: rgba(124, 108, 255, 0.14);
|
.dot.queue { background: var(--st-queue); }
|
||||||
color: var(--a-mid);
|
.dot.block { background: var(--st-block); animation: pulse-block 1.4s infinite; }
|
||||||
}
|
.dot.idle { background: var(--st-idle); }
|
||||||
|
|
||||||
.prio-low {
|
.stl {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
margin-left: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: var(--tx-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Title ─────────────────────────────────────────── */
|
.tt {
|
||||||
.task-title {
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
margin-top: 7px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
color: var(--tx);
|
color: var(--tx);
|
||||||
line-height: 1.4;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 1;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Agent ─────────────────────────────────────────── */
|
.ow {
|
||||||
.task-agent {
|
font-size: 10.5px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 9px;
|
|
||||||
color: var(--tx-3);
|
color: var(--tx-3);
|
||||||
font-variant-numeric: tabular-nums;
|
margin-top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Progress Bar ──────────────────────────────────── */
|
.skeleton {
|
||||||
.task-progress {
|
height: 78px;
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-track {
|
|
||||||
height: 3px;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-radius: 2px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: width 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status-specific bar colors */
|
|
||||||
.task-active .bar-fill {
|
|
||||||
background: var(--grad);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-pending .bar-fill {
|
|
||||||
background: var(--grad);
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-blocked .bar-fill {
|
|
||||||
background: var(--st-block);
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Skeleton ─────────────────────────────────── */
|
|
||||||
.taskcard.skeleton {
|
|
||||||
height: 98px;
|
|
||||||
background: var(--glass);
|
background: var(--glass);
|
||||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -200,27 +139,27 @@ defineProps<{
|
|||||||
50% { opacity: 0.8; }
|
50% { opacity: 0.8; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Error ────────────────────────────────────── */
|
.tstrip-msg {
|
||||||
.task-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #fda4b0;
|
|
||||||
padding: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon { flex: 0 0 auto; font-size: 14px; }
|
|
||||||
|
|
||||||
/* ── Empty ────────────────────────────────────── */
|
|
||||||
.task-empty {
|
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--tx-3);
|
color: var(--tx-3);
|
||||||
font-style: italic;
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.tstrip {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tstrip::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tcard {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface TaskItem {
|
|||||||
priority: 'high' | 'medium' | 'low'
|
priority: 'high' | 'medium' | 'low'
|
||||||
status: 'active' | 'pending' | 'blocked'
|
status: 'active' | 'pending' | 'blocked'
|
||||||
progress: number // 0–100
|
progress: number // 0–100
|
||||||
|
detail?: string | null
|
||||||
|
source?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Agent Detail Modal Types ─────────────────── */
|
/* ── Agent Detail Modal Types ─────────────────── */
|
||||||
@@ -26,13 +28,29 @@ export interface ThinkingItem {
|
|||||||
ts: string
|
ts: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dashboard view-model for an agent detail modal (distinct from types/agent.ts AgentDetail) */
|
export interface AgentActivityItem {
|
||||||
|
time: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashboard view-model for an agent detail modal */
|
||||||
export interface AgentDetailData {
|
export interface AgentDetailData {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
role: string
|
role: string
|
||||||
|
roleBadge: string
|
||||||
model: string
|
model: string
|
||||||
status: 'work' | 'think' | 'idle'
|
status: 'work' | 'think' | 'idle'
|
||||||
|
statusLabel: string
|
||||||
|
task: string | null
|
||||||
|
goal: string | null
|
||||||
|
progress: number
|
||||||
|
elapsed: string
|
||||||
|
next: string
|
||||||
|
tokens: string
|
||||||
|
cost: string
|
||||||
|
think: string | null
|
||||||
|
md?: string
|
||||||
tokensToday: number
|
tokensToday: number
|
||||||
costToday: number
|
costToday: number
|
||||||
workload: number
|
workload: number
|
||||||
@@ -40,5 +58,6 @@ export interface AgentDetailData {
|
|||||||
lastActive: string
|
lastActive: string
|
||||||
activeTaskCount: number
|
activeTaskCount: number
|
||||||
thinking: ThinkingItem[]
|
thinking: ThinkingItem[]
|
||||||
|
activity: AgentActivityItem[]
|
||||||
availableModels: { id: string; alias: string }[]
|
availableModels: { id: string; alias: string }[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 5
|
||||||
|
const CLICK_SUPPRESSION_MS = 400
|
||||||
|
|
||||||
|
export interface FlowPosition {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
id: string
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
ox: number
|
||||||
|
oy: number
|
||||||
|
moved: boolean
|
||||||
|
raf: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseFlowCanvasInteractionsOptions {
|
||||||
|
flowRef: { value: HTMLElement | null }
|
||||||
|
renderEdges: () => void
|
||||||
|
updatePositions: (positions: Record<string, FlowPosition>) => void
|
||||||
|
selectAgent: (id: string) => void
|
||||||
|
getPositions: () => Record<string, FlowPosition>
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNode(target: EventTarget | null) {
|
||||||
|
return (target as HTMLElement | null)?.closest('.node') as HTMLElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlowCanvasInteractions(options: UseFlowCanvasInteractionsOptions) {
|
||||||
|
const drag = ref<DragState | null>(null)
|
||||||
|
const suppressClickUntil = ref(0)
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
const node = findNode(e.target)
|
||||||
|
if (!node) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
const nr = node.getBoundingClientRect()
|
||||||
|
|
||||||
|
drag.value = {
|
||||||
|
id: node.dataset.id || '',
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
ox: e.clientX - (nr.left + nr.width / 2),
|
||||||
|
oy: e.clientY - (nr.top + nr.height / 2),
|
||||||
|
moved: false,
|
||||||
|
raf: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
node.setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!drag.value) return
|
||||||
|
|
||||||
|
const currentDrag = drag.value
|
||||||
|
const dist = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
|
||||||
|
if (!currentDrag.moved && dist < DRAG_THRESHOLD) return
|
||||||
|
|
||||||
|
if (!currentDrag.moved) {
|
||||||
|
currentDrag.moved = true
|
||||||
|
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) node.classList.add('dragging')
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = options.flowRef.value
|
||||||
|
if (!flow) return
|
||||||
|
|
||||||
|
const fr = flow.getBoundingClientRect()
|
||||||
|
const x = Math.max(8, Math.min(92, ((e.clientX - currentDrag.ox - fr.left) / fr.width) * 100))
|
||||||
|
const y = Math.max(10, Math.min(92, ((e.clientY - currentDrag.oy - fr.top) / fr.height) * 100))
|
||||||
|
|
||||||
|
const node = flow.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) {
|
||||||
|
node.style.left = x + '%'
|
||||||
|
node.style.top = y + '%'
|
||||||
|
}
|
||||||
|
|
||||||
|
options.updatePositions({
|
||||||
|
...options.getPositions(),
|
||||||
|
[currentDrag.id]: { x, y },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentDrag.raf) {
|
||||||
|
currentDrag.raf = requestAnimationFrame(() => {
|
||||||
|
options.renderEdges()
|
||||||
|
if (drag.value) drag.value.raf = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (!drag.value) return
|
||||||
|
|
||||||
|
const currentDrag = drag.value
|
||||||
|
const endDistance = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
|
||||||
|
const wasDragged = currentDrag.moved || endDistance >= DRAG_THRESHOLD
|
||||||
|
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
|
||||||
|
if (node) node.classList.remove('dragging')
|
||||||
|
|
||||||
|
if (wasDragged) {
|
||||||
|
suppressClickUntil.value = performance.now() + CLICK_SUPPRESSION_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
drag.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClick(e: MouseEvent) {
|
||||||
|
const node = findNode(e.target)
|
||||||
|
if (!node) return
|
||||||
|
if (performance.now() < suppressClickUntil.value) return
|
||||||
|
|
||||||
|
const id = node.dataset.id
|
||||||
|
if (id) options.selectAgent(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClickCapture(e: MouseEvent) {
|
||||||
|
if (performance.now() >= suppressClickUntil.value) return
|
||||||
|
if (!findNode(e.target)) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
onClick,
|
||||||
|
onClickCapture,
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,14 @@ import { useAgentStore } from '../../stores/agents'
|
|||||||
import { useTaskStore } from '../../stores/tasks'
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
import { navigation, icons } from '../../composables/icons'
|
import { navigation, icons } from '../../composables/icons'
|
||||||
import type { NavGroupDef } from '../../composables/icons'
|
import type { NavGroupDef } from '../../composables/icons'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
mobileOpen?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
import NavGroup from './NavGroup.vue'
|
import NavGroup from './NavGroup.vue'
|
||||||
import { initials } from '../../utils/format'
|
import { initials } from '../../utils/format'
|
||||||
|
|
||||||
@@ -63,7 +71,8 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="sidebar">
|
<aside :class="['sidebar', { open: mobileOpen }]">
|
||||||
|
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="side-top">
|
<div class="side-top">
|
||||||
<div class="brand-mark" v-html="icons.command || ''"></div>
|
<div class="brand-mark" v-html="icons.command || ''"></div>
|
||||||
@@ -171,6 +180,54 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
|||||||
background: rgba(124,108,255,.06);
|
background: rgba(124,108,255,.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close:hover {
|
||||||
|
background: rgba(124,108,255,.1);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close :deep(svg) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
|||||||
@@ -3,11 +3,19 @@ import { icons } from '../../composables/icons'
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
connected?: boolean
|
connected?: boolean
|
||||||
|
statusLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
'toggle-sidebar': []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
<!-- Hamburger (mobile only) -->
|
||||||
|
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<span class="search-icon" v-html="icons.search || ''"></span>
|
<span class="search-icon" v-html="icons.search || ''"></span>
|
||||||
@@ -20,13 +28,13 @@ defineProps<{
|
|||||||
<!-- Status Pill -->
|
<!-- Status Pill -->
|
||||||
<span :class="['pill', connected ? 'live' : 'preview']">
|
<span :class="['pill', connected ? 'live' : 'preview']">
|
||||||
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
|
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
|
||||||
{{ connected ? 'Verbunden' : 'Preview' }}
|
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Ask Iris Button -->
|
<!-- Ask Iris Button -->
|
||||||
<button class="btn btn-primary">
|
<button class="btn btn-primary ask-iris-btn">
|
||||||
<span class="btn-icon" v-html="icons.spark || ''"></span>
|
<span class="btn-icon" v-html="icons.spark || ''"></span>
|
||||||
Ask Iris
|
<span class="ask-label">Ask Iris</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -138,4 +146,65 @@ defineProps<{
|
|||||||
height: 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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { extraAgentPool } from './useFlowLayout'
|
||||||
|
import type { AgentNodeData } from './useFlowLayout'
|
||||||
|
|
||||||
|
interface FlowBoardAgentStore {
|
||||||
|
agents: AgentNodeData[]
|
||||||
|
models: Array<{ id: string; alias: string }>
|
||||||
|
changeModel: (agentId: string, modelId: string) => void
|
||||||
|
selectAgent: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowBoardChatStore {
|
||||||
|
sendMessage: (text: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'nexus-flow-positions'
|
||||||
|
|
||||||
|
function readStoredPositions() {
|
||||||
|
if (typeof window === 'undefined') return {}
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||||
|
return raw ? JSON.parse(raw) as Record<string, { x: number; y: number }> : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlowBoardState(agentStore: FlowBoardAgentStore, chatStore: FlowBoardChatStore) {
|
||||||
|
const agentPositions = ref<Record<string, { x: number; y: number }>>(readStoredPositions())
|
||||||
|
const enteringIds = ref<string[]>([])
|
||||||
|
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
|
||||||
|
|
||||||
|
function selectAgent(id: string) {
|
||||||
|
agentStore.selectAgent(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAgent() {
|
||||||
|
agentStore.selectAgent(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeModel(agentId: string, modelAlias: string) {
|
||||||
|
const model = agentStore.models.find(m => m.alias === modelAlias)
|
||||||
|
const modelId = model?.id ?? modelAlias
|
||||||
|
agentStore.changeModel(agentId, modelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAgent() {
|
||||||
|
const next = localAgentPool.value.shift()
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
enteringIds.value = [...enteringIds.value, next.id]
|
||||||
|
agentStore.agents.push(next)
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
enteringIds.value = enteringIds.value.filter(id => id !== next.id)
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLayout() {
|
||||||
|
agentPositions.value = {}
|
||||||
|
if (typeof window !== 'undefined') window.localStorage.removeItem(STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositions(positions: Record<string, { x: number; y: number }>) {
|
||||||
|
agentPositions.value = { ...positions }
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(agentPositions.value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendChatMessage(text: string) {
|
||||||
|
chatStore.sendMessage(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
addAgent,
|
||||||
|
agentPositions,
|
||||||
|
changeModel,
|
||||||
|
closeAgent,
|
||||||
|
enteringIds,
|
||||||
|
resetLayout,
|
||||||
|
selectAgent,
|
||||||
|
sendChatMessage,
|
||||||
|
updatePositions,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,46 @@
|
|||||||
* NexusLayout — V2 Dashboard Shell
|
* NexusLayout — V2 Dashboard Shell
|
||||||
* Flex row, 100vh, overflow hidden.
|
* Flex row, 100vh, overflow hidden.
|
||||||
* Sidebar (248px) + Main (flex:1, flex-column)
|
* Sidebar (248px) + Main (flex:1, flex-column)
|
||||||
|
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
|
||||||
*/
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import { useDashboardStore } from '../stores/dashboard'
|
||||||
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||||
import Sidebar from '../components/layout/Sidebar.vue'
|
import Sidebar from '../components/layout/Sidebar.vue'
|
||||||
import Topbar from '../components/layout/Topbar.vue'
|
import Topbar from '../components/layout/Topbar.vue'
|
||||||
|
|
||||||
|
const dashboardStore = useDashboardStore()
|
||||||
|
|
||||||
|
/* ── Mobile Sidebar State ───────────────────────── */
|
||||||
|
const mobileMenuOpen = ref(false)
|
||||||
|
|
||||||
|
function closeMobileMenu() {
|
||||||
|
mobileMenuOpen.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="nexus-layout">
|
<div class="nexus-layout">
|
||||||
<GalaxyBackground />
|
<GalaxyBackground />
|
||||||
<Sidebar />
|
<Sidebar
|
||||||
|
:mobile-open="mobileMenuOpen"
|
||||||
|
@close="closeMobileMenu"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Mobile Backdrop -->
|
||||||
|
<div
|
||||||
|
v-if="mobileMenuOpen"
|
||||||
|
class="mobile-backdrop"
|
||||||
|
@click="closeMobileMenu"
|
||||||
|
></div>
|
||||||
|
|
||||||
<main class="nexus-main">
|
<main class="nexus-main">
|
||||||
<Topbar />
|
<Topbar
|
||||||
|
:connected="dashboardStore.isGatewayConnected"
|
||||||
|
:status-label="dashboardStore.irisStatusLabel"
|
||||||
|
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
|
||||||
|
/>
|
||||||
<div class="nexus-content">
|
<div class="nexus-content">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +70,25 @@ import Topbar from '../components/layout/Topbar.vue'
|
|||||||
|
|
||||||
.nexus-content {
|
.nexus-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
padding: 18px 20px;
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.nexus-main {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-backdrop {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 99;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { apiFetch } from '../services/api'
|
import { apiFetch } from '../services/api'
|
||||||
import type { AgentNodeData } from '../composables/useFlowLayout'
|
import type { AgentNodeData } from '../composables/useFlowLayout'
|
||||||
import type { AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
|
import type { AgentActivityItem, AgentDetailData, ThinkingItem } from '../components/dashboard/v2/types'
|
||||||
|
|
||||||
/* ── API Response Shapes ──────────────────────────── */
|
/* ── API Response Shapes ──────────────────────────── */
|
||||||
|
|
||||||
@@ -27,6 +27,11 @@ interface DashboardAgentInfo {
|
|||||||
progress?: number
|
progress?: number
|
||||||
workload?: number
|
workload?: number
|
||||||
goal?: string | null
|
goal?: string | null
|
||||||
|
roleBadge?: string
|
||||||
|
statusLabel?: string
|
||||||
|
elapsed?: string | null
|
||||||
|
think?: string | null
|
||||||
|
next?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelOption {
|
interface ModelOption {
|
||||||
@@ -35,23 +40,9 @@ interface ModelOption {
|
|||||||
provider: string
|
provider: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Agent Catalog (static enrichment) ────────────── */
|
interface AgentActivityEntry {
|
||||||
|
time: string
|
||||||
// Type-safe catalog for static AgentNodeData fields not provided by API
|
text: string
|
||||||
interface AgentCatalogEntry {
|
|
||||||
elapsed: string;
|
|
||||||
think: string | null;
|
|
||||||
next: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AGENT_CATALOG: Record<string, AgentCatalogEntry> = {
|
|
||||||
iris: { elapsed: '--', think: null, next: 'Standby' },
|
|
||||||
programmer: { elapsed: '--', think: null, next: 'Standby' },
|
|
||||||
developer: { elapsed: '--', think: null, next: 'Standby' },
|
|
||||||
architekt: { elapsed: '--', think: null, next: 'Standby' },
|
|
||||||
reviewer: { elapsed: '--', think: null, next: 'Standby' },
|
|
||||||
executor: { elapsed: '--', think: null, next: 'Standby' },
|
|
||||||
researcher: { elapsed: '--', think: null, next: 'Standby' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Status Mapping ───────────────────────────────── */
|
/* ── Status Mapping ───────────────────────────────── */
|
||||||
@@ -78,24 +69,24 @@ function avatarFor(id: string, name: string): string {
|
|||||||
/* ── Enrich API Agent → AgentNodeData ─────────────── */
|
/* ── Enrich API Agent → AgentNodeData ─────────────── */
|
||||||
|
|
||||||
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
|
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
|
||||||
const cat = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']!
|
|
||||||
const status = mapStatus(api.isActive, api.currentTask)
|
const status = mapStatus(api.isActive, api.currentTask)
|
||||||
return {
|
return {
|
||||||
id: api.id,
|
id: api.id,
|
||||||
name: api.name,
|
name: api.name,
|
||||||
role: api.role,
|
role: api.role,
|
||||||
|
roleBadge: api.roleBadge ?? 'badge-slate',
|
||||||
model: api.model,
|
model: api.model,
|
||||||
avatar: avatarFor(api.id, api.name),
|
avatar: avatarFor(api.id, api.name),
|
||||||
status,
|
status,
|
||||||
statusLabel: STATUS_LABELS[status],
|
statusLabel: api.statusLabel ?? STATUS_LABELS[status],
|
||||||
task: api.currentTask,
|
task: api.currentTask,
|
||||||
goal: api.goal ?? null,
|
goal: api.goal ?? null,
|
||||||
progress: api.progress ?? 0,
|
progress: api.progress ?? 0,
|
||||||
elapsed: cat.elapsed ?? '--',
|
elapsed: api.elapsed ?? '--',
|
||||||
next: cat.next ?? 'Standby',
|
next: api.next ?? 'Standby',
|
||||||
tokens: '0',
|
tokens: '0',
|
||||||
cost: '0.00',
|
cost: '0.00',
|
||||||
think: cat.think ?? null,
|
think: api.think ?? null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +133,19 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
role: data.role,
|
role: data.role,
|
||||||
|
roleBadge: data.roleBadge || 'badge-slate',
|
||||||
model: displayModel,
|
model: displayModel,
|
||||||
status: data.status === 'block' ? 'idle' : data.status,
|
status: data.status === 'block' ? 'idle' : data.status,
|
||||||
|
statusLabel: data.statusLabel,
|
||||||
|
task: data.task,
|
||||||
|
goal: data.goal,
|
||||||
|
progress,
|
||||||
|
elapsed: data.elapsed || '—',
|
||||||
|
next: data.next || '—',
|
||||||
|
tokens: data.tokens || '0',
|
||||||
|
cost: data.cost || '0.00',
|
||||||
|
think: data.think,
|
||||||
|
md: data.md,
|
||||||
tokensToday,
|
tokensToday,
|
||||||
costToday: costNum,
|
costToday: costNum,
|
||||||
workload: progress,
|
workload: progress,
|
||||||
@@ -151,6 +153,7 @@ export function buildAgentDetail(data: AgentNodeData, models: { id: string; alia
|
|||||||
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
|
lastActive: data.elapsed !== '—' ? 'Vor ' + data.elapsed : 'Nicht aktiv',
|
||||||
activeTaskCount: data.task ? 1 : 0,
|
activeTaskCount: data.task ? 1 : 0,
|
||||||
thinking: buildThinkingItems(data),
|
thinking: buildThinkingItems(data),
|
||||||
|
activity: [],
|
||||||
availableModels: models,
|
availableModels: models,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +165,9 @@ export const useAgentStore = defineStore('agents', {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
selectedAgentId: null as string | null,
|
selectedAgentId: null as string | null,
|
||||||
|
activityByAgentId: {} as Record<string, AgentActivityItem[]>,
|
||||||
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||||
|
isConnected: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -181,7 +186,10 @@ export const useAgentStore = defineStore('agents', {
|
|||||||
if (!state.selectedAgentId) return null
|
if (!state.selectedAgentId) return null
|
||||||
const data = state.agents.find(a => a.id === state.selectedAgentId)
|
const data = state.agents.find(a => a.id === state.selectedAgentId)
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
return buildAgentDetail(data, state.models)
|
return {
|
||||||
|
...buildAgentDetail(data, state.models),
|
||||||
|
activity: state.activityByAgentId[data.id] ?? [],
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Is the modal open? */
|
/** Is the modal open? */
|
||||||
@@ -211,10 +219,12 @@ export const useAgentStore = defineStore('agents', {
|
|||||||
async fetchAgents() {
|
async fetchAgents() {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/dashboard/agents')
|
const res = await apiFetch('/api/dashboard/agents')
|
||||||
if (!res.ok) return
|
if (!res.ok) { this.isConnected = false; return }
|
||||||
const data: DashboardAgentInfo[] = await res.json()
|
const data: DashboardAgentInfo[] = await res.json()
|
||||||
this.agents = data.map(enrichAgent)
|
this.agents = data.map(enrichAgent)
|
||||||
|
this.isConnected = true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
this.isConnected = false
|
||||||
console.warn('[AgentStore] fetchAgents failed', err)
|
console.warn('[AgentStore] fetchAgents failed', err)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -249,9 +259,24 @@ export const useAgentStore = defineStore('agents', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async fetchAgentActivity(agentId: string) {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/dashboard/agents/${encodeURIComponent(agentId)}/activity?limit=5`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data: AgentActivityEntry[] = await res.json()
|
||||||
|
this.activityByAgentId[agentId] = data.map(entry => ({
|
||||||
|
time: entry.time,
|
||||||
|
text: entry.text,
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[AgentStore] fetchAgentActivity failed', err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/* ── Selection ───────────────────────────────── */
|
/* ── Selection ───────────────────────────────── */
|
||||||
selectAgent(id: string | null) {
|
selectAgent(id: string | null) {
|
||||||
this.selectedAgentId = id
|
this.selectedAgentId = id
|
||||||
|
if (id) void this.fetchAgentActivity(id)
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ── Polling ─────────────────────────────────── */
|
/* ── Polling ─────────────────────────────────── */
|
||||||
@@ -262,7 +287,7 @@ export const useAgentStore = defineStore('agents', {
|
|||||||
this.refreshInterval = setInterval(() => {
|
this.refreshInterval = setInterval(() => {
|
||||||
this.fetchAgents()
|
this.fetchAgents()
|
||||||
this.fetchModels()
|
this.fetchModels()
|
||||||
}, 30000)
|
}, 15000)
|
||||||
},
|
},
|
||||||
|
|
||||||
stopPolling() {
|
stopPolling() {
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { apiFetch } from '../services/api'
|
||||||
|
|
||||||
|
interface DashboardStatusDto {
|
||||||
|
gatewayOk: boolean
|
||||||
|
irisStatus: string
|
||||||
|
activeAgents: number
|
||||||
|
pendingTasks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedEntryDto {
|
||||||
|
agent: string
|
||||||
|
action: string
|
||||||
|
timestamp: string
|
||||||
|
time: string
|
||||||
|
agentId?: string | null
|
||||||
|
type?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueueItemDto {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
priority: string
|
||||||
|
source: string
|
||||||
|
waitTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDashboardStore = defineStore('dashboard', {
|
||||||
|
state: () => ({
|
||||||
|
status: null as DashboardStatusDto | null,
|
||||||
|
operations: [] as FeedEntryDto[],
|
||||||
|
queue: [] as QueueItemDto[],
|
||||||
|
loading: false,
|
||||||
|
error: null as string | null,
|
||||||
|
refreshInterval: null as ReturnType<typeof setInterval> | null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
isGatewayConnected: state => state.status?.gatewayOk ?? false,
|
||||||
|
irisStatusLabel: state => state.status?.irisStatus ?? 'Offline',
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchStatus() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/status')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
this.status = await res.json()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[DashboardStore] fetchStatus failed', err)
|
||||||
|
this.status = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchOperations() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/operations?limit=20')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
this.operations = await res.json()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[DashboardStore] fetchOperations failed', err)
|
||||||
|
this.operations = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchQueue() {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/dashboard/queue')
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
this.queue = await res.json()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[DashboardStore] fetchQueue failed', err)
|
||||||
|
this.queue = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
this.fetchStatus(),
|
||||||
|
this.fetchOperations(),
|
||||||
|
this.fetchQueue(),
|
||||||
|
])
|
||||||
|
this.error = null
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[DashboardStore] refresh failed', err)
|
||||||
|
this.error = 'Dashboard metadata could not be loaded'
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startPolling() {
|
||||||
|
if (this.refreshInterval) return
|
||||||
|
this.refresh()
|
||||||
|
this.refreshInterval = setInterval(() => {
|
||||||
|
this.refresh()
|
||||||
|
}, 30000)
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPolling() {
|
||||||
|
if (this.refreshInterval) {
|
||||||
|
clearInterval(this.refreshInterval)
|
||||||
|
this.refreshInterval = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -56,6 +56,8 @@ function mapTask(t: DashboardTaskDto): TaskItem {
|
|||||||
priority: mapPriority(t.priority),
|
priority: mapPriority(t.priority),
|
||||||
status: mapState(t.state),
|
status: mapState(t.state),
|
||||||
progress: mapProgress(t.state),
|
progress: mapProgress(t.state),
|
||||||
|
detail: t.detail,
|
||||||
|
source: t.source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,84 +12,62 @@
|
|||||||
*
|
*
|
||||||
* Polling startet bei Mount, stoppt bei Unmount.
|
* Polling startet bei Mount, stoppt bei Unmount.
|
||||||
*/
|
*/
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { useAgentStore } from '../../stores/agents'
|
import { useAgentStore } from '../../stores/agents'
|
||||||
import { useChatStore } from '../../stores/chat'
|
import { useChatStore } from '../../stores/chat'
|
||||||
|
import { useDashboardStore } from '../../stores/dashboard'
|
||||||
import { useTaskStore } from '../../stores/tasks'
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||||
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
import TaskStrip from '../../components/dashboard/v2/TaskStrip.vue'
|
||||||
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
import AgentDetailModal from '../../components/dashboard/v2/AgentDetailModal.vue'
|
||||||
import type { AgentNodeData } from '../../composables/useFlowLayout'
|
import { useFlowBoardState } from '../../composables/useFlowBoardState'
|
||||||
import { extraAgentPool } from '../../composables/useFlowLayout'
|
|
||||||
|
|
||||||
/* ── Stores ──────────────────────────────────────── */
|
/* ── Stores ──────────────────────────────────────── */
|
||||||
const agentStore = useAgentStore()
|
const agentStore = useAgentStore()
|
||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
|
const dashboardStore = useDashboardStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
/* ── Agent Layout State ───────────────────────────── */
|
const {
|
||||||
const agentPositions = ref<Record<string, { x: number; y: number }>>({})
|
addAgent,
|
||||||
const enteringIds = ref<string[]>([])
|
agentPositions,
|
||||||
const localAgentPool = ref<AgentNodeData[]>([...extraAgentPool])
|
changeModel,
|
||||||
|
closeAgent,
|
||||||
/* ── Event Handlers ───────────────────────────────── */
|
enteringIds,
|
||||||
|
resetLayout,
|
||||||
function handleSelect(id: string) {
|
selectAgent,
|
||||||
agentStore.selectAgent(id)
|
sendChatMessage,
|
||||||
}
|
updatePositions,
|
||||||
|
} = useFlowBoardState(agentStore, chatStore)
|
||||||
function handleCloseModal() {
|
|
||||||
agentStore.selectAgent(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChangeModel(agentId: string, modelAlias: string) {
|
|
||||||
// Modal emits the alias (display name); resolve to model ID for the API
|
|
||||||
const model = agentStore.models.find(m => m.alias === modelAlias)
|
|
||||||
const modelId = model?.id ?? modelAlias
|
|
||||||
agentStore.changeModel(agentId, modelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAdd() {
|
|
||||||
const pool = localAgentPool.value
|
|
||||||
if (pool.length === 0) return
|
|
||||||
const next = pool.shift()!
|
|
||||||
enteringIds.value.push(next.id)
|
|
||||||
agentStore.agents.push(next)
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
const idx = enteringIds.value.indexOf(next.id)
|
|
||||||
if (idx !== -1) enteringIds.value.splice(idx, 1)
|
|
||||||
}, 600)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResetLayout() {
|
|
||||||
agentPositions.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdatePositions(pos: Record<string, { x: number; y: number }>) {
|
|
||||||
agentPositions.value = { ...pos }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlockerClick() {
|
function handleBlockerClick() {
|
||||||
console.log('[FlowBoard] blocker clicked')
|
console.log('[FlowBoard] blocker clicked')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChatSend(text: string) {
|
function blockerLabel() {
|
||||||
chatStore.sendMessage(text)
|
const blockedTask = taskStore.taskList.find(task => task.status === 'blocked')
|
||||||
|
if (!blockedTask) return undefined
|
||||||
|
return `${taskStore.taskList.filter(task => task.status === 'blocked').length} Blocker — ${blockedTask.title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockerCount() {
|
||||||
|
return taskStore.taskList.filter(task => task.status === 'blocked').length
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Lifecycle ────────────────────────────────────── */
|
/* ── Lifecycle ────────────────────────────────────── */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
agentStore.startPolling()
|
agentStore.startPolling()
|
||||||
chatStore.startPolling()
|
chatStore.startPolling()
|
||||||
|
dashboardStore.startPolling()
|
||||||
taskStore.startPolling()
|
taskStore.startPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
agentStore.stopPolling()
|
agentStore.stopPolling()
|
||||||
chatStore.stopPolling()
|
chatStore.stopPolling()
|
||||||
|
dashboardStore.stopPolling()
|
||||||
taskStore.stopPolling()
|
taskStore.stopPolling()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -104,9 +82,10 @@ onUnmounted(() => {
|
|||||||
:active-count="agentStore.activeCount"
|
:active-count="agentStore.activeCount"
|
||||||
:think-count="agentStore.thinkCount"
|
:think-count="agentStore.thinkCount"
|
||||||
:idle-count="agentStore.idleCount"
|
:idle-count="agentStore.idleCount"
|
||||||
:blocker-count="agentStore.blockerCount"
|
:blocker-count="blockerCount()"
|
||||||
:today-cost="agentStore.todayCost"
|
:today-cost="agentStore.todayCost"
|
||||||
:today-tokens="agentStore.todayTokens"
|
:today-tokens="agentStore.todayTokens"
|
||||||
|
:blocker-label="blockerLabel()"
|
||||||
@blocker-click="handleBlockerClick"
|
@blocker-click="handleBlockerClick"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -114,10 +93,10 @@ onUnmounted(() => {
|
|||||||
:agents="agentStore.agentList"
|
:agents="agentStore.agentList"
|
||||||
:positions="agentPositions"
|
:positions="agentPositions"
|
||||||
:entering-ids="enteringIds"
|
:entering-ids="enteringIds"
|
||||||
@select="handleSelect"
|
@select="selectAgent"
|
||||||
@add="handleAdd"
|
@add="addAgent"
|
||||||
@reset-layout="handleResetLayout"
|
@reset-layout="resetLayout"
|
||||||
@update-positions="handleUpdatePositions"
|
@update-positions="updatePositions"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
|
<TaskStrip :tasks="taskStore.taskList" :loading="taskStore.loading" :error="taskStore.error" />
|
||||||
@@ -128,7 +107,7 @@ onUnmounted(() => {
|
|||||||
:messages="chatStore.messageList"
|
:messages="chatStore.messageList"
|
||||||
:is-thinking="chatStore.isThinking"
|
:is-thinking="chatStore.isThinking"
|
||||||
:error="chatStore.error"
|
:error="chatStore.error"
|
||||||
@send="handleChatSend"
|
@send="sendChatMessage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,9 +116,9 @@ onUnmounted(() => {
|
|||||||
v-if="agentStore.modalOpen && agentStore.selectedAgent"
|
v-if="agentStore.modalOpen && agentStore.selectedAgent"
|
||||||
:agent="agentStore.selectedAgent"
|
:agent="agentStore.selectedAgent"
|
||||||
:agent-order="agentStore.agentOrder"
|
:agent-order="agentStore.agentOrder"
|
||||||
@close="handleCloseModal"
|
@close="closeAgent"
|
||||||
@select="handleSelect"
|
@select="selectAgent"
|
||||||
@change-model="handleChangeModel"
|
@change-model="changeModel"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -162,7 +141,9 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 0;
|
gap: 18px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +152,20 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
padding: 0 18px 0 0;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.board-body {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+39
-6
@@ -25,13 +25,46 @@ docker compose ps
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "[4/4] Verifikation..."
|
echo "[4/4] Verifikation..."
|
||||||
curl -fsS http://localhost:18880/health && echo " ✅ Health-Check bestanden"
|
check_code() {
|
||||||
|
local path="$1"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" "http://localhost:18880${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
HEALTH_CODE=$(check_code /health)
|
||||||
|
DASHBOARD_CODE=$(check_code /dashboard)
|
||||||
|
OPS_CODE=$(check_code /api/v1/operations/snapshot)
|
||||||
|
|
||||||
|
if [ "$HEALTH_CODE" = "200" ] && [ "$DASHBOARD_CODE" != "200" ]; then
|
||||||
|
WEB_CID="$(docker compose ps -q web || true)"
|
||||||
|
if [ -n "$WEB_CID" ]; then
|
||||||
|
WEB_STATE="$(docker inspect -f '{{.State.Status}}' "$WEB_CID" 2>/dev/null || true)"
|
||||||
|
if [ "$WEB_STATE" = "created" ]; then
|
||||||
|
echo " ℹ️ API healthy, aber web noch im Status 'created' — starte web nach"
|
||||||
|
docker compose up -d web
|
||||||
|
sleep 2
|
||||||
|
DASHBOARD_CODE=$(check_code /dashboard)
|
||||||
|
OPS_CODE=$(check_code /api/v1/operations/snapshot)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " /health -> ${HEALTH_CODE}"
|
||||||
|
echo " /dashboard -> ${DASHBOARD_CODE}"
|
||||||
|
echo " /api/v1/operations/snapshot -> ${OPS_CODE}"
|
||||||
|
|
||||||
|
if [ "$HEALTH_CODE" != "200" ] || [ "$DASHBOARD_CODE" != "200" ] || [ "$OPS_CODE" != "401" ]; then
|
||||||
|
echo " ❌ Verifikation fehlgeschlagen"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " ✅ Health-Check bestanden"
|
||||||
|
echo " ✅ Dashboard erreichbar"
|
||||||
|
echo " ✅ Operations API fordert Auth an"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Fertig ==="
|
echo "=== Deployment abgeschlossen ==="
|
||||||
echo "Nexus Web: http://nexus.noveria.net:18880"
|
echo "Dashboard: https://nexus.noveria.net/dashboard"
|
||||||
echo "Login: vmbao62@hotmail.de"
|
echo "Health-API: https://nexus.noveria.net/health"
|
||||||
echo "Passwort: wird beim ersten Start im Container-Log ausgegeben"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Logs: docker compose logs api | grep 'Initial owner'"
|
echo "Login-Informationen: docker compose logs api | grep 'Initial owner'"
|
||||||
echo "Status: docker compose ps"
|
echo "Status: docker compose ps"
|
||||||
|
|||||||
@@ -34,14 +34,17 @@ systemctl daemon-reload
|
|||||||
systemctl enable --now ollama
|
systemctl enable --now ollama
|
||||||
systemctl restart ollama
|
systemctl restart ollama
|
||||||
|
|
||||||
for attempt in {1..30}; do
|
max_attempts=30
|
||||||
|
attempt=1
|
||||||
|
while [[ "${attempt}" -le "${max_attempts}" ]]; do
|
||||||
if curl -fsS "http://${BIND_ADDRESS}/api/tags" >/dev/null; then
|
if curl -fsS "http://${BIND_ADDRESS}/api/tags" >/dev/null; then
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if [[ "${attempt}" -eq 30 ]]; then
|
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
|
||||||
systemctl status ollama --no-pager
|
systemctl status ollama --no-pager
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
attempt=$((attempt + 1))
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# Noveria.net Landingpage — Nginx Server Block
|
||||||
|
# ==============================================================================
|
||||||
|
# Diese Config gehört in den Host-Nginx unter /etc/nginx/sites-available/
|
||||||
|
# und muss via Symlink nach /etc/nginx/sites-enabled/ aktiviert werden.
|
||||||
|
#
|
||||||
|
# WICHTIG: Falls "noveria.net" oder "www.noveria.net" bereits in einem anderen
|
||||||
|
# Serverblock (z.B. dem nexus.noveria.net-Block) als server_name auftaucht,
|
||||||
|
# muss es dort entfernt werden, sonst schlägt nginx -t fehl.
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name noveria.net www.noveria.net;
|
||||||
|
|
||||||
|
# SSL (gleiche Zertifikate wie nexus)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/noveria.net/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/noveria.net/privkey.pem;
|
||||||
|
include /etc/nginx/snippets/ssl-params.conf;
|
||||||
|
|
||||||
|
# Security Header
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:18881;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTP → HTTPS redirect
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name noveria.net www.noveria.net;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Diagnose-Kommandos (auf dem Host auszuführen, nicht im Container!)
|
||||||
|
# ==============================================================================
|
||||||
|
# 1. Prüfen ob noveria.net bereits in bestehender Config referenziert wird
|
||||||
|
# grep -rn "noveria.net" /etc/nginx/sites-available/
|
||||||
|
# grep -rn "www.noveria.net" /etc/nginx/sites-available/
|
||||||
|
#
|
||||||
|
# 2. Config testen nach Änderung
|
||||||
|
# nginx -t
|
||||||
|
#
|
||||||
|
# 3. Nginx neuladen
|
||||||
|
# systemctl reload nginx
|
||||||
|
# ==============================================================================
|
||||||
+8
-1
@@ -1,7 +1,14 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
> Letzte Aktualisierung: 2026-06-09
|
> Letzte Aktualisierung: 2026-06-16
|
||||||
|
|
||||||
|
- 2026-06-16: Program.cs refactored: DI extrahiert in `Extensions/ServiceCollectionExtensions.cs`, Middleware in `Extensions/ApplicationBuilderExtensions.cs`, Helpers in `Helpers/PasswordHelper.cs`. Program.cs von ~200 auf 26 Zeilen reduziert.
|
||||||
|
- 2026-06-16: Nexus auf Netcup (mission-control) redeployed. Neuer Stack unter `/home/projekte_bao/nexus/`. Traefik reverse-proxy mit Let's Encrypt TLS. Volume und Netzwerk-Namen bereinigt (postgres-data, internal). Compose-Pfade von Ionos auf Netcup migriert.
|
||||||
|
- 2026-06-16: Ollama-Modelle (2.4 GB) und alle ungenutzten Runtime-Dateien entfernt. Codex-Logs bereinigt (~342 MB). Workspace-Aufräumung (~3.1 GB gesamt).
|
||||||
|
- 2026-06-16: Modell-Healthcheck nach Migration: Alle 7 aktiven Modelle laufen (DeepSeek Flash/Pro, GPT-5.4/5.5, Claude Sonnet/Opus via CLI-Backend). Ollama und NVIDIA endgültig deaktiviert.
|
||||||
|
- 2026-06-14: Server-Migration von Ionos (85.214.180.137) nach Netcup (178.105.105.106). Hostname: mission-control. Migration: OpenClaw, Gitea, Nexus-Volume.
|
||||||
|
- 2026-06-12: Agent-Workspaces finalisiert. Iris als Chief of Staff mit Approval-Autonomie. Bidirektionale Kommunikation etabliert.
|
||||||
|
- 2026-06-11: Gitea CI/CD-Pipeline aktiv. Agent-Repo-Permissions mit API-Tokens (statt Passwort-Auth). DevOps-Token für Deploy-Trigger.
|
||||||
- 2026-06-09: Phase 2 Backend + Frontend implementiert: Memory-Browser (Liste, Detail, Volltextsuche), Docs-Browser (Kategorien, Filter), Team-Org-Map (Karten + Kommunikationsmatrix), Security-Center (Auth, Tokens, Rate-Limit, Cookies). Backend-Build 0 Errors, Frontend-Build (vue-tsc + vite) 0 Errors.
|
- 2026-06-09: Phase 2 Backend + Frontend implementiert: Memory-Browser (Liste, Detail, Volltextsuche), Docs-Browser (Kategorien, Filter), Team-Org-Map (Karten + Kommunikationsmatrix), Security-Center (Auth, Tokens, Rate-Limit, Cookies). Backend-Build 0 Errors, Frontend-Build (vue-tsc + vite) 0 Errors.
|
||||||
- 2026-06-09: Researcher-Agent zum Team hinzugefügt (DeepSeek V4 Pro, Nur-Lese-Rechte, YouTube-Vision-Skill). Kommunikationsmatrix erweitert (Researcher↔Iris only).
|
- 2026-06-09: Researcher-Agent zum Team hinzugefügt (DeepSeek V4 Pro, Nur-Lese-Rechte, YouTube-Vision-Skill). Kommunikationsmatrix erweitert (Researcher↔Iris only).
|
||||||
- 2026-06-09: Phase 1 komplettiert: Live-Agentinventar, Dashboard-Metriken, Approval-Workflow, Healthchecks (PostgreSQL + Runtime), Tests (Backend 3/3 + Frontend 2/2).
|
- 2026-06-09: Phase 1 komplettiert: Live-Agentinventar, Dashboard-Metriken, Approval-Workflow, Healthchecks (PostgreSQL + Runtime), Tests (Backend 3/3 + Frontend 2/2).
|
||||||
|
|||||||
+147
-9
@@ -1,18 +1,132 @@
|
|||||||
# Deployment
|
# Deployment
|
||||||
|
|
||||||
> Letzte Aktualisierung: 2026-06-09
|
> Letzte Aktualisierung: 2026-06-13
|
||||||
> Status: ✅ Deployment abgeschlossen
|
> Status: ✅ CD v3 (Auto + Manual)
|
||||||
> Live-URL: https://nexus.noveria.net
|
> Live-URL: https://nexus.noveria.net
|
||||||
|
|
||||||
## Ziel
|
## CD-Philosophie (v3)
|
||||||
|
|
||||||
Nach Phase 1 soll das Mission-Control-Board deployt und die Infrastruktur so gesetzt sein, dass Bao direkt draufkommen kann.
|
- **CI läuft automatisch** bei jedem Push → darf nie brechen
|
||||||
|
- **CD auto + manuell**: Automaticher Deploy nach CI-Success auf main (patch default), manueller Deploy mit voller Kontrolle via `workflow_dispatch`
|
||||||
|
- **Loop-Schutz**: Version-Bump-Commits enthalten `[skip ci]` — kein Re-Trigger der CI, kein Infinite-Loop
|
||||||
|
- **Main-Deploys** duerfen VERSION bumpen und einen Git-Tag setzen
|
||||||
|
- **Nicht-Main-Deploys** (anderer `git_ref`) deployen read-only und mutieren Git nicht
|
||||||
|
- **Rollback** als eigener Workflow, manuell triggerbar
|
||||||
|
- **Database-Backup** als eigener Workflow, manuell triggerbar (optionaler Nightly-Schedule)
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
### Deploy (`.gitea/workflows/deploy.yaml`)
|
||||||
|
|
||||||
|
**Trigger**:
|
||||||
|
- **Automatisch**: Nach erfolgreicher CI (`workflow_run` auf `CI - Build & Test`)
|
||||||
|
→ Default-Parameter: patch bump, all services, main ref
|
||||||
|
- **Manuell**: Via Gitea Actions → `workflow_dispatch`
|
||||||
|
|
||||||
|
**Loop-Schutz**:
|
||||||
|
- Version-Bump-Commits enthalten `[skip ci]` → Gitea startet keine neue CI
|
||||||
|
- Auto-Deploy prüft zusätzlich `github.event.workflow_run.head_commit.message` auf `[skip ci]`
|
||||||
|
- Beide Mechanismen zusammen verhindern Endlosschleife: CI → Deploy → Bump → CI …
|
||||||
|
|
||||||
|
**Inputs** (nur bei `workflow_dispatch`):
|
||||||
|
| Input | Typ | Default | Beschreibung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `version_bump` | choice (patch/minor/major) | patch | Version-Bump-Typ |
|
||||||
|
| `service` | string | (all) | Einzelner Service oder alle |
|
||||||
|
| `no_cache` | boolean | false | Docker-Build-Cache deaktivieren |
|
||||||
|
| `git_ref` | string | main | Branch/Tag/Commit zum Deployen |
|
||||||
|
|
||||||
|
**Ablauf**:
|
||||||
|
1. Job-Level-Guard: Auto-Deploys fuer `[skip ci]`-Commits werden gar nicht gestartet
|
||||||
|
2. Checkout des gewählten Git-Refs
|
||||||
|
3. Wenn `git_ref = main`: Version-Bump + Git-Tag + Push
|
||||||
|
4. Wenn `git_ref != main`: VERSION nur lesen, kein Push, kein Tag
|
||||||
|
5. **Safe Secret Handling**: `.env` wird aus Secret-Umgebungsvariablen in `/tmp/nexus-deploy-env` geschrieben (mode 600), **NICHT** im Workspace
|
||||||
|
6. Code-Sync zum Host-Deploy-Pfad
|
||||||
|
7. `docker compose build && up -d --wait --force-recreate`
|
||||||
|
8. `.env`-Tempfile wird mit `shred` gelöscht
|
||||||
|
9. Health-Check (exponentieller Backoff, 6 Versuche)
|
||||||
|
10. Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
|
||||||
|
11. Bei Fehler: Reviewer-Handoff-Meldung mit Job-URL
|
||||||
|
|
||||||
|
### Backup (`.gitea/workflows/backup.yaml`)
|
||||||
|
|
||||||
|
**Trigger**: Manuell via Gitea Actions → `workflow_dispatch` (optional: Nightly-Schedule via Cron)
|
||||||
|
|
||||||
|
**Inputs**:
|
||||||
|
| Input | Typ | Default | Beschreibung |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `keep_on_host` | boolean | false | Backup auch auf Host-Pfad kopieren |
|
||||||
|
| `host_backup_path` | string | `/opt/openclaw/backups` | Host-Zielpfad |
|
||||||
|
|
||||||
|
**Ablauf**:
|
||||||
|
1. Backup-ID generieren (Timestamp-basiert)
|
||||||
|
2. `docker exec nexus-postgres-1 pg_dumpall -U nexus` → gzip
|
||||||
|
3. Upload als Gitea-Artifact (90 Tage Retention, bereits komprimiert)
|
||||||
|
4. Optional: Kopie auf Host-Pfad via Docker-Volume-Mount
|
||||||
|
5. Integritäts-Check: gzip-Test + SQL-Header-Validierung
|
||||||
|
6. Backup-Summary mit Restore-Befehl
|
||||||
|
|
||||||
|
**Restore (manuell auf dem Host)**:
|
||||||
|
```bash
|
||||||
|
# Aus Gitea-Artifact herunterladen oder von Host-Pfad:
|
||||||
|
zcat nexus-backup-YYYY-MM-DDTHHMMSSZ.sql.gz | docker exec -i nexus-postgres-1 psql -U nexus -d postgres
|
||||||
|
# Danach Stack neu starten:
|
||||||
|
cd /opt/openclaw/data/openclaw/workspace/nexus
|
||||||
|
docker compose up -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nightly-Schedule aktivieren**:
|
||||||
|
In `backup.yaml` die Zeilen auskommentieren:
|
||||||
|
```yaml
|
||||||
|
schedule:
|
||||||
|
- cron: '0 3 * * *' # Jede Nacht um 03:00 UTC
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback (`.gitea/workflows/rollback.yaml`)
|
||||||
|
|
||||||
|
**Trigger**: Manuell via Gitea Actions → `workflow_dispatch`
|
||||||
|
|
||||||
|
**Inputs**:
|
||||||
|
| Input | Typ | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `target_tag` | string | Git-Tag zum Zurückrollen (z.B. `v0.2.49`) |
|
||||||
|
| `confirm` | string | Muss exakt `ROLLBACK` sein (Safety-Gate) |
|
||||||
|
|
||||||
|
**Ablauf**:
|
||||||
|
1. Safety-Gate: Bestätigungstext muss `ROLLBACK` sein
|
||||||
|
2. Checkout des Target-Tags
|
||||||
|
3. Tag-Validierung (existiert? welcher Commit?)
|
||||||
|
4. Safe Secret Handling (gleiches Tempfile-Pattern)
|
||||||
|
5. Code-Sync des alten Stands zum Host
|
||||||
|
6. `docker compose build --no-cache && up -d --wait --force-recreate`
|
||||||
|
7. Health-Check + Smoke-Test (`/dashboard`, `/health`, `/api/v1/operations/snapshot` erwartet `401`)
|
||||||
|
8. Bei Fehler: Reviewer-Handoff mit manueller Rollback-Anleitung
|
||||||
|
|
||||||
|
**DB-Migration bei Rollback**: Die API führt `MigrateAsync` beim Start aus. Wenn die Migrationen des Rollback-Tags ein Prefix der aktuellen DB sind (Normalfall), läuft EF Core sie als No-Op. Wenn ein Rollback-Tag vor einer destruktiven Migration liegt, ist manuelles DB-Intervention nötig — ein Edge Case, der DevOps signalisiert wird.
|
||||||
|
|
||||||
## Secrets und Konfiguration
|
## Secrets und Konfiguration
|
||||||
|
|
||||||
- [x] `.env.template` mit allen erforderlichen Variablen erstellt
|
### Secrets in Gitea
|
||||||
- [x] Produktions-`.env` mit starken, getrennten Secrets angelegt
|
|
||||||
- [x] Migration des Produktionsstacks getestet
|
Folgende Secrets sind in Gitea (Repo → Settings → Actions → Secrets) konfiguriert:
|
||||||
|
|
||||||
|
| Secret | Verwendung |
|
||||||
|
|---|---|
|
||||||
|
| `ENV_POSTGRES_PASSWORD` | PostgreSQL-Passwort |
|
||||||
|
| `ENV_JWT_KEY` | JWT-Signing-Key (min. 32 Bytes) |
|
||||||
|
| `ENV_OWNER_PASSWORD` | Owner-Account-Passwort |
|
||||||
|
| `ENV_OPENCLAW_TOKEN` | OpenClaw Gateway Token |
|
||||||
|
|
||||||
|
### Safe Secret Handling (v3)
|
||||||
|
|
||||||
|
**Vorher (unsicher)**: Secrets wurden via `${{ secrets.X }}` direkt in eine Datei im Workspace interpoliert, die dann zum Host synct wurde. Das `.env` lag potenziell lesbar im Workspace und auf dem Host-Dateisystem.
|
||||||
|
|
||||||
|
**Jetzt (sicher)**:
|
||||||
|
1. Secrets werden als Step-Environment aus Gitea Secrets bezogen und erst dann in `/tmp/nexus-deploy-env` (mode 600) geschrieben
|
||||||
|
2. Die Temp-Datei wird via `docker run -v` als read-only ins Compose-Environment gemountet
|
||||||
|
3. Nach Deploy/Rollback wird die Datei mit `shred -u` gelöscht
|
||||||
|
4. Das `.env` erscheint **nie** im Workspace oder auf dem Host-Deploy-Pfad
|
||||||
|
|
||||||
## Build-Anleitung (lokal oder in CI)
|
## Build-Anleitung (lokal oder in CI)
|
||||||
|
|
||||||
@@ -54,6 +168,13 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
|
|||||||
- [x] Nginx mit Let's Encrypt SSL fuer `nexus.noveria.net` konfiguriert
|
- [x] Nginx mit Let's Encrypt SSL fuer `nexus.noveria.net` konfiguriert
|
||||||
- [x] HTTPS, Security-Header (HSTS, X-Content-Type-Options, X-Frame-Options), Cookies validiert
|
- [x] HTTPS, Security-Header (HSTS, X-Content-Type-Options, X-Frame-Options), Cookies validiert
|
||||||
- [x] Externe Erreichbarkeit bestaetigt (2026-06-09)
|
- [x] Externe Erreichbarkeit bestaetigt (2026-06-09)
|
||||||
|
- [x] CI/CD entkoppelt — Deploy darf automatisch (v3) oder manuell (2026-06-13)
|
||||||
|
- [x] Automatischer Deploy nach CI-Success auf main mit Loop-Schutz via [skip ci] (2026-06-13)
|
||||||
|
- [x] Safe Secret Handling: Tempfile in /tmp statt Workspace-Datei (2026-06-13)
|
||||||
|
- [x] Rollback-Workflow implementiert mit Safety-Gate (2026-06-13)
|
||||||
|
- [x] Main-Deploys koennen Version-Bump + Git-Tag automatisch setzen; Non-Main-Deploys bleiben read-only (2026-06-13)
|
||||||
|
- [x] Reviewer-Handoff bei Deploy/Rollback-Fehlern (2026-06-13)
|
||||||
|
- [x] Database-Backup-Workflow mit pg_dumpall + Gitea-Artifact (2026-06-13)
|
||||||
|
|
||||||
## Verifizierung (2026-06-09)
|
## Verifizierung (2026-06-09)
|
||||||
|
|
||||||
@@ -64,8 +185,25 @@ Stelle sicher, dass `.env` existiert und alle `***`-Platzhalter ersetzt sind.
|
|||||||
- Let's Encrypt TLS-Zertifikat aktiv
|
- Let's Encrypt TLS-Zertifikat aktiv
|
||||||
- Nginx-Proxy → 127.0.0.1:18880
|
- Nginx-Proxy → 127.0.0.1:18880
|
||||||
|
|
||||||
|
## Incident-Hinweis (2026-06-14)
|
||||||
|
|
||||||
|
- Verifizierter Ausfallpfad: `api` konnte wegen DB-Passwort-Mismatch nicht healthy werden; dadurch blieb `web` per `depends_on: service_healthy` im Status `Created`.
|
||||||
|
- Nach einem isolierten API-Fix startet `web` nicht automatisch nach. Sicherer Minimalpfad:
|
||||||
|
1. `docker compose ps`
|
||||||
|
2. `curl http://127.0.0.1:18880/health`
|
||||||
|
3. Falls `health=200`, aber `/dashboard` noch nicht `200` und `web` auf `Created` steht: `docker compose up -d web`
|
||||||
|
4. Danach extern `/dashboard`, `/health` und `/api/v1/operations/snapshot` erneut prüfen
|
||||||
|
- Der manuelle Helper [`ops/deploy.sh`](/home/node/.openclaw/workspace/nexus/ops/deploy.sh) verifiziert deshalb jetzt nicht mehr nur `/health`, sondern auch `/dashboard` und den Auth-Schutz der Operations-API.
|
||||||
|
|
||||||
## Offene Arbeit
|
## Offene Arbeit
|
||||||
|
|
||||||
- [ ] Backup-Strategie fuer Produktionsdaten definieren
|
- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
|
||||||
- [ ] Docker-Logs und Container-Health-Monitoring einrichten
|
- [ ] Docker-Logs und Container-Health-Monitoring einrichten
|
||||||
- [ ] `.gitignore` final pruefen
|
- [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren
|
||||||
|
- [ ] Direkt-Pushes auf `main` waehrend eines Main-Deploys organisatorisch vermeiden oder spaeter technisch haerter absichern
|
||||||
|
|
||||||
|
### Deploy-Trigger-Actor (2026-06-14)
|
||||||
|
|
||||||
|
- Deploy-Trigger werden durch DevOps (nicht Iris) ausgelöst
|
||||||
|
- Git-Remote origin verwendet DevOps-Token → Gitea zeigt devops als Actor
|
||||||
|
- Workflow-Dispatch API-Calls mit DevOps-Token authentifizieren
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
# Phase 1 MVP
|
# Phase 1 MVP
|
||||||
|
|
||||||
> Letzte Aktualisierung: 2026-06-09
|
> Letzte Aktualisierung: 2026-06-16
|
||||||
> Fokus: Mission-Control-Board bereitstellen und Infrastruktur anschliessen
|
> Status: ✅ Abgeschlossen
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
- Gesamtfortschritt: ca. 95 %
|
- Gesamtfortschritt: 100 % ✅
|
||||||
- Produktiv live: ja (https://nexus.noveria.net)
|
- Produktiv live: ja (https://nexus.noveria.net)
|
||||||
- Letzter Build: Backend + Frontend erfolgreich
|
- Letzter Build: Backend + Frontend erfolgreich
|
||||||
|
- Ollama/NVIDIA entfernt, nur OpenClaw-Integration
|
||||||
|
|
||||||
## Prioritaet
|
## Prioritaet
|
||||||
|
|
||||||
|
|||||||
+15
-12
@@ -1,23 +1,26 @@
|
|||||||
# Runtime und Routing
|
# Runtime und Routing
|
||||||
|
|
||||||
> Letzte Aktualisierung: 2026-06-08
|
> Letzte Aktualisierung: 2026-06-16
|
||||||
|
|
||||||
## Aktive Modelle
|
## Aktive Modelle (7 von 8 konfiguriert)
|
||||||
|
|
||||||
| Priorität | Modell | Zweck | Provider |
|
| Agent | Modell | Provider |
|
||||||
|-----------|--------|-------|----------|
|
|-------|--------|----------|
|
||||||
| 1 | deepseek/deepseek-v4-flash | Programmer Agent | DeepSeek (über OpenClaw) |
|
| Iris | `openai/gpt-5.4` | OpenAI (OAuth) |
|
||||||
| 2 | deepseek/deepseek-v4-pro | Reviewer Agent, Iris Fallback | DeepSeek (über OpenClaw) |
|
| Programmer, Executor | `deepseek/deepseek-v4-flash` | DeepSeek (API-Key) |
|
||||||
| 3 | openai/gpt-5.3-chat-latest | Iris Hauptmodell | OpenAI (über OpenClaw) |
|
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` | DeepSeek (API-Key) |
|
||||||
|
| — | `openai/gpt-5.5` | OpenAI (verfügbar) |
|
||||||
|
| — | `anthropic/claude-sonnet-4-6` | Anthropic (CLI-Backend) |
|
||||||
|
| — | `anthropic/claude-opus-4-6/4.8` | Anthropic (CLI-Backend) |
|
||||||
|
|
||||||
## Deaktiviert
|
## Entfernt / Deaktiviert
|
||||||
|
|
||||||
- **Ollama** (qwen3:4b): deaktiviert, funktioniert aktuell nicht. Wird später wieder aufgegriffen.
|
- **Ollama** (qwen3:4b): komplett entfernt (2.4 GB Models gelöscht 16.06.)
|
||||||
- **NVIDIA** (moonshotai/kimi-k2.6): vollständig entfernt.
|
- **NVIDIA** (moonshotai/kimi-k2.6): vollständig entfernt.
|
||||||
- **Kimi 2.6**: vollständig entfernt.
|
- **IModelProvider-Abstraktion**: entfernt, nur noch `IAgentRuntime` mit OpenClaw-Adapter.
|
||||||
|
|
||||||
## Integration
|
## Integration
|
||||||
|
|
||||||
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
|
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
|
||||||
- Keine direkten Provider-Registrierungen mehr im Backend (OllamaProvider, NvidiaProvider entfernt)
|
- Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
|
||||||
- Model-Routing läuft zentral über OpenClaw Gateway
|
- API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)
|
||||||
|
|||||||
Reference in New Issue
Block a user