Compare commits
26 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 |
@@ -27,10 +27,10 @@ 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
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ run-name: 🚀 Deploy by @${{ gitea.actor }}
|
|||||||
# Concurrency: one deploy at a time.
|
# Concurrency: one deploy at a time.
|
||||||
# Queued deploys wait — no race conditions with parallel builds.
|
# Queued deploys wait — no race conditions with parallel builds.
|
||||||
#
|
#
|
||||||
# Version-Bump / CI Loop Prevention:
|
# Version Management:
|
||||||
# The version-bump commit includes "[skip ci]" in its message,
|
# The VERSION file in the repo root is the single source of truth.
|
||||||
# which Gitea Actions respects. The auto-trigger additionally
|
# Version bumps happen in the Dev workflow BEFORE merge to main.
|
||||||
# checks for "[skip ci]" as a second safety layer. Together
|
# The deploy workflow only reads, validates, and logs the version.
|
||||||
# they guarantee that a version-bump commit does NOT trigger
|
# The [skip ci] filter remains as a safety layer for auto-triggers.
|
||||||
# another CI → Deploy → Bump → CI cycle.
|
|
||||||
# ───────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────
|
||||||
concurrency:
|
concurrency:
|
||||||
group: deploy-production
|
group: deploy-production
|
||||||
@@ -36,15 +35,6 @@ on:
|
|||||||
# ── Manual Trigger (full control) ──
|
# ── Manual Trigger (full control) ──
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version_bump:
|
|
||||||
description: 'Version bump type'
|
|
||||||
required: true
|
|
||||||
default: 'patch'
|
|
||||||
type: choice
|
|
||||||
options:
|
|
||||||
- patch
|
|
||||||
- minor
|
|
||||||
- major
|
|
||||||
service:
|
service:
|
||||||
description: 'Service to deploy (empty = all)'
|
description: 'Service to deploy (empty = all)'
|
||||||
required: false
|
required: false
|
||||||
@@ -102,60 +92,39 @@ jobs:
|
|||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Step 3: Resolve deploy version
|
# Step 3: Resolve deploy version
|
||||||
#
|
#
|
||||||
# Deploying main: DevOps may bump VERSION and create a tag.
|
# Reads VERSION from repo root — the single source of truth.
|
||||||
# Deploying any other ref: deploy exactly that ref, but DO NOT
|
# Validates semver format, logs version + git metadata.
|
||||||
# mutate main or create a version-bump commit on another branch.
|
# No git mutation: version bumps happen in the Dev workflow.
|
||||||
#
|
|
||||||
# For auto-deploys (workflow_run): always "patch" bump on main.
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
- name: Resolve Version
|
- name: Resolve Version
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Determine bump type (auto-deploy → patch; manual → user choice)
|
# 1. Check VERSION exists
|
||||||
BUMP_TYPE="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch' }}"
|
|
||||||
|
|
||||||
# Read current version
|
|
||||||
if [ ! -f VERSION ]; then
|
if [ ! -f VERSION ]; then
|
||||||
echo "❌ VERSION file not found"
|
echo "❌ VERSION file not found"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CURRENT=$(cat VERSION | tr -d '[:space:]')
|
# 2. Read and validate semver format
|
||||||
if ! echo "$CURRENT" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
VERSION=$(cat VERSION | tr -d '[:space:]')
|
||||||
echo "❌ Invalid semver in VERSION: '$CURRENT'"
|
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||||
|
echo "❌ Invalid semver in VERSION: '$VERSION'"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
# 3. Log version, git ref, and describe
|
||||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
GIT_REF=$(git rev-parse --short HEAD)
|
||||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
GIT_DESCRIBE=$(git describe --always --dirty)
|
||||||
|
|
||||||
case "$BUMP_TYPE" in
|
echo "📦 Deploy version: v${VERSION}"
|
||||||
major) NEW_MAJOR=$((MAJOR + 1)); NEW_MINOR=0; NEW_PATCH=0 ;;
|
echo "🔖 Git ref: ${GIT_REF}"
|
||||||
minor) NEW_MAJOR=$MAJOR; NEW_MINOR=$((MINOR + 1)); NEW_PATCH=0 ;;
|
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
|
||||||
patch) NEW_MAJOR=$MAJOR; NEW_MINOR=$MINOR; NEW_PATCH=$((PATCH + 1)) ;;
|
|
||||||
*) echo "❌ Unknown bump type: $BUMP_TYPE"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Determine git ref — auto-deploy always uses main
|
# 4. Set outputs for downstream steps
|
||||||
DEPLOY_REF="${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
|
||||||
if [ -z "$DEPLOY_REF" ] || [ "$DEPLOY_REF" = "main" ] || [ "$DEPLOY_REF" = "refs/heads/main" ]; then
|
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
||||||
NEW_VERSION="${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
|
|
||||||
echo "$NEW_VERSION" > VERSION
|
|
||||||
git add VERSION
|
|
||||||
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
|
|
||||||
git tag -a "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
|
|
||||||
git push origin HEAD:main --tags
|
|
||||||
echo "version=$NEW_VERSION" >> "$GITEA_OUTPUT"
|
|
||||||
echo "mutated_main=true" >> "$GITEA_OUTPUT"
|
|
||||||
echo "📦 Main deploy: version $CURRENT -> v${NEW_VERSION} (bump: $BUMP_TYPE, trigger: ${{ github.event_name }})"
|
|
||||||
else
|
|
||||||
echo "version=$CURRENT" >> "$GITEA_OUTPUT"
|
|
||||||
echo "mutated_main=false" >> "$GITEA_OUTPUT"
|
|
||||||
echo "📦 Non-main deploy from '$DEPLOY_REF': using committed VERSION $CURRENT without git mutation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
# Step 4: Build .env from secrets (SAFE)
|
# Step 4: Build .env from secrets (SAFE)
|
||||||
@@ -234,12 +203,14 @@ jobs:
|
|||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
-v "${ENV_TMPFILE}:/tmp/nexus-deploy-env:ro" \
|
|
||||||
-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
|
||||||
|
trap 'rm -f /tmp/nexus-deploy-env' EXIT
|
||||||
|
cat > /tmp/nexus-deploy-env
|
||||||
if [ -n '${SERVICE_ARG}' ]; then
|
if [ -n '${SERVICE_ARG}' ]; then
|
||||||
echo '🚀 Deploying service: ${SERVICE_ARG}'
|
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 build ${BUILD_ARGS} ${SERVICE_ARG}
|
||||||
@@ -249,7 +220,7 @@ jobs:
|
|||||||
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
|
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
|
||||||
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
||||||
fi
|
fi
|
||||||
"
|
" < "${ENV_TMPFILE}"
|
||||||
|
|
||||||
echo "✅ Docker compose up completed"
|
echo "✅ Docker compose up completed"
|
||||||
|
|
||||||
@@ -332,17 +303,14 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
|
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
|
||||||
VERSION_BUMP="${{ github.event_name == 'workflow_dispatch' && inputs.version_bump || 'patch (auto)' }}"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "═══════════════════════════════════════"
|
echo "═══════════════════════════════════════"
|
||||||
echo " 📦 Deploy Summary"
|
echo " 📦 Deploy Summary"
|
||||||
echo "═══════════════════════════════════════"
|
echo "═══════════════════════════════════════"
|
||||||
echo " Version: v${{ steps.version.outputs.version }}"
|
echo " Version: v${{ steps.version.outputs.version }}"
|
||||||
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
|
||||||
echo " Main bump: ${{ steps.version.outputs.mutated_main }}"
|
|
||||||
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
|
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
|
||||||
echo " Trigger: ${TRIGGER}"
|
echo " Trigger: ${TRIGGER}"
|
||||||
echo " Bump type: ${VERSION_BUMP}"
|
|
||||||
echo " Actor: @${{ gitea.actor }}"
|
echo " Actor: @${{ gitea.actor }}"
|
||||||
echo " Status: ${{ job.status }}"
|
echo " Status: ${{ job.status }}"
|
||||||
echo "═══════════════════════════════════════"
|
echo "═══════════════════════════════════════"
|
||||||
|
|||||||
@@ -151,15 +151,15 @@ jobs:
|
|||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
-v "${ENV_TMPFILE}:/tmp/nexus-deploy-env:ro" \
|
-v "/tmp:/tmp-host:ro" \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh -c "
|
sh -c "
|
||||||
set -e
|
set -e
|
||||||
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||||
docker compose --env-file /tmp/nexus-deploy-env build --no-cache
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||||
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||||
"
|
"
|
||||||
|
|
||||||
echo "✅ Rollback redeploy completed"
|
echo "✅ Rollback redeploy completed"
|
||||||
|
|||||||
@@ -15,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
|
||||||
|
|
||||||
@@ -31,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
|
||||||
|
|
||||||
@@ -45,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
|
||||||
|
|
||||||
@@ -283,11 +281,16 @@ 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.
|
||||||
@@ -316,7 +319,7 @@ Deployment can happen automatically or manually:
|
|||||||
|
|
||||||
#### Manual Deploy (`workflow_dispatch`)
|
#### Manual Deploy (`workflow_dispatch`)
|
||||||
|
|
||||||
1. DevOps triggers `Deploy to Production` in Gitea Actions
|
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
|
||||||
2. Chooses version bump type: patch (default) / minor / major
|
2. Chooses version bump type: patch (default) / minor / major
|
||||||
3. Optionally scopes to a single service or specific git ref
|
3. Optionally scopes to a single service or specific git ref
|
||||||
4. Workflow bumps VERSION, creates git tag, builds and deploys
|
4. Workflow bumps VERSION, creates git tag, builds and deploys
|
||||||
@@ -332,7 +335,7 @@ Deployment can happen automatically or manually:
|
|||||||
#### Database Backup (`workflow_dispatch`)
|
#### Database Backup (`workflow_dispatch`)
|
||||||
|
|
||||||
1. DevOps triggers `Database Backup` in Gitea Actions
|
1. DevOps triggers `Database Backup` in Gitea Actions
|
||||||
2. Optionally also copies backup to a host path (`/opt/openclaw/backups`)
|
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
|
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
|
||||||
4. Artifacts are retained for 90 days (configurable)
|
4. Artifacts are retained for 90 days (configurable)
|
||||||
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
|
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -92,9 +92,28 @@ public class AgentsController(
|
|||||||
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 result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
try
|
||||||
return result is null
|
{
|
||||||
? Results.BadRequest(new { error = "Invalid filename or path." })
|
var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
|
||||||
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
return result is null
|
||||||
|
? Results.BadRequest(new { error = "Invalid filename or path." })
|
||||||
|
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Nexus.Api.Services;
|
using Nexus.Api.Services;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ namespace Nexus.Api.Controllers;
|
|||||||
public class OperationsController(IOperationsService operationsService) : ControllerBase
|
public class OperationsController(IOperationsService operationsService) : 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));
|
=> Results.Ok(await operationsService.GetSnapshotAsync(ct));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-222
@@ -1,234 +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<IOpenClawGatewayClient, 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>();
|
|
||||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
|
||||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
|
||||||
builder.Services.AddScoped<ITaskService, TaskService>();
|
|
||||||
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
|
||||||
builder.Services.AddScoped<ITeamService, TeamService>();
|
|
||||||
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
|
||||||
builder.Services.AddSingleton<IMemoryService, MemoryService>();
|
|
||||||
builder.Services.AddSingleton<IIncidentService, IncidentService>();
|
|
||||||
builder.Services.AddSingleton<IDocService, DocService>();
|
|
||||||
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
|
||||||
|
|
||||||
// --- 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,16 +15,13 @@ public sealed class OperationsService(
|
|||||||
{
|
{
|
||||||
var runtimeTask = runtime.GetStatusAsync(ct);
|
var runtimeTask = runtime.GetStatusAsync(ct);
|
||||||
var agentsTask = agentService.GetAgentsAsync(ct);
|
var agentsTask = agentService.GetAgentsAsync(ct);
|
||||||
var projectsTask = projectRepo.GetAllAsync(ct);
|
// Repository calls share the scoped EF Core DbContext and must stay serialized.
|
||||||
var tasksTask = taskRepo.GetAllAsync(ct);
|
var projects = await projectRepo.GetAllAsync(ct);
|
||||||
var activityTask = activityRepo.GetRecentAsync(20, ct);
|
var tasks = await taskRepo.GetAllAsync(ct);
|
||||||
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
|
var activity = await activityRepo.GetRecentAsync(20, ct);
|
||||||
|
var agents = await agentsTask;
|
||||||
var tasks = tasksTask.Result;
|
|
||||||
var projects = projectsTask.Result;
|
|
||||||
var agents = agentsTask.Result;
|
|
||||||
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
|
||||||
var runtimeStatus = runtimeTask.Result;
|
var runtimeStatus = await runtimeTask;
|
||||||
|
|
||||||
var lastIncident = tasks
|
var lastIncident = tasks
|
||||||
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
|
||||||
@@ -56,7 +53,7 @@ public sealed class OperationsService(
|
|||||||
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
|
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 }),
|
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 }),
|
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 })
|
activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -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", "wget --no-verbose --tries=1 --spider 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,6 +87,12 @@ const statusColors: Record<string, string> = {
|
|||||||
idle: 'var(--st-idle)',
|
idle: 'var(--st-idle)',
|
||||||
block: 'var(--st-block)',
|
block: 'var(--st-block)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function avatarLabel() {
|
||||||
|
if (props.agent.id === 'iris') return 'IR'
|
||||||
|
if (props.agent.name === 'Full-Stack Developer') return '</>'
|
||||||
|
return props.agent.name.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -99,7 +105,7 @@ const statusColors: Record<string, string> = {
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="m-head">
|
<div class="m-head">
|
||||||
<div :class="['m-av', { iris: agent.id === 'iris' }]">
|
<div :class="['m-av', { iris: agent.id === 'iris' }]">
|
||||||
{{ agent.id === 'iris' ? 'IR' : agent.name.slice(0, 2).toUpperCase() }}
|
{{ avatarLabel() }}
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1; min-width:0">
|
<div style="flex:1; min-width:0">
|
||||||
<div class="m-name">{{ agent.name }}</div>
|
<div class="m-name">{{ agent.name }}</div>
|
||||||
@@ -160,6 +166,16 @@ const statusColors: Record<string, string> = {
|
|||||||
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
|
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="agent.activity.length" class="m-sec">
|
||||||
|
<h4>Agent Activity</h4>
|
||||||
|
<div class="m-activity">
|
||||||
|
<div v-for="(entry, index) in agent.activity" :key="index" class="m-activity-row">
|
||||||
|
<span class="m-activity-time">{{ entry.time }}</span>
|
||||||
|
<span class="m-activity-text">{{ entry.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modell wählen -->
|
<!-- Modell wählen -->
|
||||||
<div class="m-sec">
|
<div class="m-sec">
|
||||||
<h4>Modell wählen</h4>
|
<h4>Modell wählen</h4>
|
||||||
@@ -519,6 +535,35 @@ const statusColors: Record<string, string> = {
|
|||||||
|
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
@keyframes blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
.m-activity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-activity-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 72px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-activity-time {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--tx-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-activity-text {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--tx-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Models ──────────────────────────────────── */
|
/* ── Models ──────────────────────────────────── */
|
||||||
.m-models {
|
.m-models {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -251,6 +251,22 @@ watch(
|
|||||||
|
|
||||||
@keyframes blink { 50% { opacity: 0; } }
|
@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 ───────────────────────────────────── */
|
/* ── Input ───────────────────────────────────── */
|
||||||
.chat-in {
|
.chat-in {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|||||||
@@ -146,4 +146,20 @@ function statusLabel(s: TaskItem['status']): string {
|
|||||||
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,6 +28,11 @@ export interface ThinkingItem {
|
|||||||
ts: string
|
ts: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentActivityItem {
|
||||||
|
time: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
/** Dashboard view-model for an agent detail modal */
|
/** Dashboard view-model for an agent detail modal */
|
||||||
export interface AgentDetailData {
|
export interface AgentDetailData {
|
||||||
id: string
|
id: string
|
||||||
@@ -51,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 ? 'OpenClaw 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,22 +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 { useAgentStore } from '../stores/agents'
|
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 agentStore = useAgentStore()
|
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 :connected="agentStore.isConnected" />
|
<Topbar
|
||||||
|
:connected="dashboardStore.isGatewayConnected"
|
||||||
|
:status-label="dashboardStore.irisStatusLabel"
|
||||||
|
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
|
||||||
|
/>
|
||||||
<div class="nexus-content">
|
<div class="nexus-content">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
@@ -49,4 +73,22 @@ const agentStore = useAgentStore()
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
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 ──────────────────────────── */
|
||||||
|
|
||||||
@@ -40,6 +40,11 @@ interface ModelOption {
|
|||||||
provider: string
|
provider: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentActivityEntry {
|
||||||
|
time: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Status Mapping ───────────────────────────────── */
|
/* ── Status Mapping ───────────────────────────────── */
|
||||||
|
|
||||||
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
|
function mapStatus(isActive: boolean, currentTask: string | null): AgentNodeData['status'] {
|
||||||
@@ -148,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,6 +165,7 @@ 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,
|
isConnected: false,
|
||||||
}),
|
}),
|
||||||
@@ -179,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? */
|
||||||
@@ -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 ─────────────────────────────────── */
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -177,4 +156,16 @@ onUnmounted(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.board-body {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+35
-1
@@ -25,7 +25,41 @@ 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 "=== Deployment abgeschlossen ==="
|
echo "=== Deployment abgeschlossen ==="
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -185,9 +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
|
||||||
|
|
||||||
- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
|
- [ ] Docker-Socket-Risiko im CD-Workflow final adressieren (kommt spaeter)
|
||||||
- [ ] Docker-Logs und Container-Health-Monitoring einrichten
|
- [ ] Docker-Logs und Container-Health-Monitoring einrichten
|
||||||
- [ ] Restore-Drill fuer Backup/Recovery einmal realistisch durchspielen und dokumentieren
|
- [ ] 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
|
- [ ] 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