Compare commits

...

11 Commits

Author SHA1 Message Date
devops 4633e570e8 fix(agents): finish sanitized config migration — GatewayClient also reads agents-sanitized.json
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped
The previous commit f4bee44 updated AgentService.cs but missed
OpenClawGatewayClient.cs which also reads agent IDs and model config
from the raw openclaw.json. Both LoadAgentIdsFromConfig() and
GetAvailableModels() now default to /etc/nexus/agents-sanitized.json.

This completes the P4 migration: Nexus no longer needs read access to
openclaw.json for any code path.
2026-07-12 14:09:31 +02:00
devops f4bee442db feat: sanitized agent config — Nexus no longer reads secrets from openclaw.json
CI - Build & Test / Backend (.NET) (push) Successful in 35s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped
- Mount agents-sanitized.json (agents key only, no secrets) instead of full openclaw.json
- Update AgentService default path from /home/node/.openclaw/openclaw.json to /etc/nexus/agents-sanitized.json
- Add AgentConfigPath env var to compose for explicit path configuration
- Generate sanitized file in deploy-nexus.sh before each deploy using Python extraction
- Add agents-sanitized.json to .gitignore

Eliminates the fragile ACL on openclaw.json (uid 1654) that causes 500 errors
on the Board endpoint when lost.
2026-07-12 13:08:43 +02:00
devops 7f1d5b706d fix(deploy): harden deploy script — remove force-recreate, add Auth-Smoke
Remove --force-recreate from docker compose up so Postgres persists
across deploys unless its image or config actually changed.

Add Auth Smoke checks before declaring deploy success:
- SeedAudit owner_created key must exist in DB
- Owner login flow must return 401 (invalid_credentials) — proving
  auth pipeline is functional and DB is reachable

Deploy fails (exit 1) if any smoke check fails (fail-closed).
46 lines changed in deploy-nexus.sh.
2026-07-11 17:03:49 +02:00
devops c3e0e6913b test: add EnsureDatabaseAsync SeedAudit regression tests for owner password re-seed guard
Adds 4 tests in EnsureDatabaseSeedAuditTests:
- EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash
- EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword
- EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner
- EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner

Verifies that once SeedAudit contains owner_created, subsequent
EnsureDatabaseAsync calls (simulating pod restarts) do NOT reset
the owner's password hash. Covers the owner_created SeedAudit guard.
2026-07-11 16:08:53 +02:00
devops b82d88563a feat(ui): consolidate shared UI patterns into reusable components
- Token cleanup: replace raw hex colors (#c084fc, #60a5fa, #6ee7b7, #fdba74)
  in BoardCard.vue with nexus-tokens.css CSS variables (--clr-iris, --clr-bao,
  --clr-agent, --clr-review)
- New composable: useFormatDate (formatDate, relativeTime, toDateInputValue,
  minutesSince, hoursSince) — extracts duplicated date helpers from
  TaskBoardView and BoardCard
- New composable: useConfirm — reusable confirmation dialog logic
  (open/close, error/success state, Escape binding, body scroll lock)
- New component: StatusPill — unified state pill for backlog/progress/
  review/blocked/done, replacing inline .detail-state-pill classes
- New component: SkeletonLoader — loading placeholder with shimmer
  animation (card/text/circle variants)
- TaskBoardView: imports StatusPill & useFormatDate, removes 35+ lines
  of duplicated helpers
- ui/index.ts: exports new StatusPill & SkeletonLoader
- Build verified: pnpm build green (vue-tsc --noEmit + vite build pass)
2026-07-11 14:11:02 +02:00
devops 7de12c6541 fix(ui): eliminate remaining hardcoded hex colors from TaskBoardView
Replace all #hex literals with nexus-token CSS variables:
- iris badges/dots/assignee/banner → var(--clr-iris)
- bao dots/assignee/meta-chip → var(--clr-bao)
- agent assignee → var(--clr-agent)
- stale banners/badges/dots/counts → var(--clr-stale)
- review btn-changes/state pill → var(--clr-review)
- other dots/column ring → var(--clr-other)
- expected badge → var(--a-purple)
- success flash/state pill → var(--pill-progress)
- backlog state pill → var(--pill-backlog)
- all #fff references → var(--tx)

Add --pill-* state pill color tokens to nexus-tokens.css.
Fix #fff in nexus-btn-gradient in tokens.css.

Task: ea7c2063
Ref: 50d95fa (V2 Design-Fundament)
2026-07-11 13:06:33 +02:00
devops b093b0c4b5 restore: reapply 50d95fa UI foundation clobbered by sync in 2ee1fe9
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
My rsync --delete based deploy sync overwrote the working tree with a stale
local copy and accidentally reverted commit 50d95fa (V2 Design-Fundament,
task ea7c2063) — new ui/ components deleted, token/color work in
dashboard/v2 and nexus-tokens.css rolled back. This restores all 17 files
exactly from 50d95fa; no overlap with the modal rework files (TaskBoardView,
tasks store, BoardCard), which stay as committed in 2ee1fe9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:13:04 +02:00
devops 2ee1fe973f feat(board): rework quick-peek modal (Linear-style) + board toolbar
CI - Build & Test / Backend (.NET) (push) Successful in 33s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Modal redesign — compact summary instead of two long cards:
- Topbar: breadcrumb + state pill + copyable task-id/link chip; review
  actions (approve / request changes) live in the modal header too
- Main column: title, meta chips (ball, created, last update), compact
  description, then square summary tiles (done/total with progress,
  active, open, blocked) + per-agent pills — details stay in Vollansicht
- Right sidebar: properties as slim Linear-style rows (status, priority,
  assignee, due, source) with hover-reveal controls; activity moved next
  to properties as a compact timeline with a new comment composer
  (POST tasks/{id}/activity via new store action postTaskActivity)
- Unified button system (32px: primary/ghost/approve/changes/icon)
- flattenBoard includes nested children again so quick-peek works for
  child rows inside master cards

Board (issue-tracker basics):
- Toolbar with search (title/detail/id/child titles) and ball filters:
  Alle / Du bist dran / Bei Iris / Haengt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:10:51 +02:00
developer 50d95fa7a9 feat(ui): V2 Design-Fundament — UI-Komponenten konsolidieren und tokenisieren
## Neue wiederverwendbare Komponenten in components/ui/:
- StatusDot.vue — Animierte Status-Punkte (work/think/idle/block/queue)
- PageHeading.vue — Standardisierter Gradient-Seiten-Header
- EmptyState.vue — Shared Empty-State mit Icon/Title/Description/Action
- ActivityTimeline.vue — Wiederverwendbarer Aktivitäts-Feed
- SectionHeader.vue — Spalten-/Sektions-Header (dot + label + count)

## Token-Konformität hergestellt:
- Badge.vue: +12 nexus-token-basierte Varianten (work/think/blocked/queue/done/review/iris/bao/agent/priority*)
- Card.vue: glass-panel/raised/subtle Varianten via nexus-tokens.css
- button/index.ts: +gradient, +icon, +ghostSubtle, +danger Varianten
- nexus-tokens.css: Buttons + --clr-* Semantic Colors (iris/bao/agent/review/stale/other)

## Hartkodierte Farben eliminiert (0 verbleibend):
- dashboard/v2/*: alle #hex → var(--)* ersetzt
- components/ui/*: alle #hex → var(--)* ersetzt

Task: ea7c2063
Build:  grün
2026-07-11 11:09:31 +02:00
devops aef76d5f45 feat(board): master-task board, non-destructive stall watchdog, review flow
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
  nested inside their parent card instead of as separate column cards, so a
  big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
  children directly instead of scraping the flat board

Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
  tasks with no activity past the threshold as stalled (activity event +
  Iris notification) WITHOUT resetting the column — no work is discarded.
  Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
  interval 10m); hard reset kept only on the explicit manual endpoint

Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
  ExpectedFrom=iris, notifies Iris)

Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
  children, expand to show children grouped by agent with per-child state +
  stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions

Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:26:50 +02:00
devops f564ecfbc7 feat: unified rail shell, robust live sync, task board performance
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Has been skipped
Shell:
- One shared NexusLayout for ALL routes (dashboard + pages): compact 68px
  icon rail with hover-expand overlay, replaces both old sidebars + topbars
- Single flat nav source (railNav) — same menu everywhere incl. Settings
- Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup,
  NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView

Live updates:
- Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and
  ChannelReader.ReadAsync were re-invoked while pending — every published
  update threw InvalidOperationException and killed ALL live streams
- liveSync store: treat graceful stream close as disconnect (was stuck
  connected=true with polling stopped -> page frozen until manual reload),
  fast first retry, heartbeat watchdog (65s), reconnect on online/visibility
- One app-wide SSE connection owned by the layout instead of per-view
  connect/disconnect churn; removed duplicate live-sync.ts store

Performance:
- Board endpoint: drop nested childTasks duplication (counts stay) — payload
  104KB -> 65KB; SSE snapshots shrink equally
- nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire);
  text/event-stream excluded to keep SSE unbuffered

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:25:26 +02:00
58 changed files with 3048 additions and 2766 deletions
+68 -2
View File
@@ -5,6 +5,7 @@ DEPLOY_PATH="${DEPLOY_PATH:-/home/projekte_bao/nexus}"
ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}" ENV_TMPFILE_TEMPLATE="${ENV_TMPFILE:-/tmp/nexus-deploy-env}"
ENV_TMPFILE="" ENV_TMPFILE=""
BASE_URL="${BASE_URL:-https://nexus.noveria.net}" BASE_URL="${BASE_URL:-https://nexus.noveria.net}"
BOOTSTRAP_OWNER_EMAIL="${BOOTSTRAP_OWNER_EMAIL_DEPLOY:-vmbao62@hotmail.de}"
cleanup() { cleanup() {
if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then if [ -n "$ENV_TMPFILE" ] && [ -f "$ENV_TMPFILE" ]; then
@@ -59,7 +60,7 @@ POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY} JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web JWT_AUDIENCE=nexus-web
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de BOOTSTRAP_OWNER_EMAIL=${BOOTSTRAP_OWNER_EMAIL}
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789 OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-} OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
OPENCLAW_GATEWAY_PASSWORD= OPENCLAW_GATEWAY_PASSWORD=
@@ -105,6 +106,28 @@ git archive --format=tar HEAD | docker run --rm -i \
chown -R "$dest_owner" /dest chown -R "$dest_owner" /dest
' '
# ── Sanitized agents config for Nexus (no secrets) ──
echo "Generating sanitized agents config for Nexus"
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
if [ -f "$OPENCLAW_CONFIG" ]; then
python3 -c "
import json, sys
with open('$OPENCLAW_CONFIG') as f:
data = json.load(f)
agents = data.get('agents')
if agents is None:
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
sys.exit(1)
with open('$AGENTS_SANITIZED_PATH', 'w') as f:
json.dump({'agents': agents}, f, indent=2)
"
chmod 644 "$AGENTS_SANITIZED_PATH" 2>/dev/null || true
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
else
echo "WARNING: openclaw.json not found at $OPENCLAW_CONFIG — agents-sanitized.json NOT generated" >&2
fi
echo "Building and starting Docker compose stack" echo "Building and starting Docker compose stack"
docker run --rm \ docker run --rm \
-v "$DEPLOY_PATH:/workspace/nexus" \ -v "$DEPLOY_PATH:/workspace/nexus" \
@@ -117,7 +140,14 @@ docker run --rm \
cat > /tmp/nexus-deploy-env cat > /tmp/nexus-deploy-env
trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM trap '\''rm -f /tmp/nexus-deploy-env'\'' EXIT INT TERM
docker compose --env-file /tmp/nexus-deploy-env build docker compose --env-file /tmp/nexus-deploy-env build
docker compose --env-file /tmp/nexus-deploy-env up -d --force-recreate --remove-orphans --wait
# ── Postgres: only recreate if image or config changed ──
# docker compose up -d (without --force-recreate) is smart enough
# to only recreate containers whose config or image has changed.
# We DROP --force-recreate so postgres persists across deploys
# unless its image tag or compose config actually changed.
docker compose --env-file /tmp/nexus-deploy-env up -d --remove-orphans --wait
docker compose --env-file /tmp/nexus-deploy-env ps docker compose --env-file /tmp/nexus-deploy-env ps
' < "$ENV_TMPFILE" ' < "$ENV_TMPFILE"
@@ -190,9 +220,45 @@ check "/health" "200" "Health"
check "/api/v1/operations/snapshot" "401" "Operations auth" check "/api/v1/operations/snapshot" "401" "Operations auth"
check_post "/api/v1/chat" "401" "Chat auth" check_post "/api/v1/chat" "401" "Chat auth"
# ── Auth Smoke: SeedAudit owner_created exists in DB ──
echo ""
echo "Auth Smoke: SeedAudit owner_created"
seed_key="$(docker exec nexus-postgres-1 psql -U nexus -d nexus -t -A -c "SELECT key FROM \"SeedAudit\" WHERE key = 'owner_created'" 2>/dev/null || echo "")"
seed_key="$(echo "$seed_key" | tr -d '[:space:]')"
if [ "$seed_key" = "owner_created" ]; then
echo " SeedAudit owner_created: ✅ exists"
pass=$((pass + 1))
else
echo " SeedAudit owner_created: ❌ NOT FOUND (DB may not be seeded)" >&2
echo " Raw output: '$seed_key'" >&2
fail=$((fail + 1))
fi
# ── Auth Smoke: Owner login flow returns 401 for unknown password ──
# This proves the user exists, auth pipeline is functional, and the DB is reachable.
# We POST with a WRONG password intentionally — a 401 means "user found, password wrong",
# which is the correct auth flow behavior. A 5xx or connection error means the stack is broken.
echo "Auth Smoke: Owner login flow"
login_body="$(curl -sS --max-time 10 \
-X POST \
-H 'Content-Type: application/json' \
-d "{\"email\":\"${BOOTSTRAP_OWNER_EMAIL}\",\"password\":\"smoke-test-wrong-password-$(date +%s)\"}" \
"$BASE_URL/api/v1/auth/login" 2>/dev/null || echo "CONNECTION_ERROR")"
if echo "$login_body" | grep -q '"error":"invalid_credentials"'; then
echo " Owner login flow: ✅ HTTP 401 with valid JSON (auth pipeline working)"
pass=$((pass + 1))
else
echo " Owner login flow: ❌ unexpected response" >&2
echo " Response: $(echo "$login_body" | head -c 200)" >&2
fail=$((fail + 1))
fi
if [ "$fail" -ne 0 ]; then if [ "$fail" -ne 0 ]; then
echo ""
echo "Smoke test failed: $fail failed, $pass passed" >&2 echo "Smoke test failed: $fail failed, $pass passed" >&2
exit 1 exit 1
fi fi
echo ""
echo "Nexus v$VERSION deployed and verified" echo "Nexus v$VERSION deployed and verified"
+3
View File
@@ -39,3 +39,6 @@ frontend/.corepack-home/
# Claude local config (per-developer, not repo-shared) # Claude local config (per-developer, not repo-shared)
.claude/ .claude/
# Sanitized agent config (generated on host, not committed)
backend/agents-sanitized.json
@@ -0,0 +1,232 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
/// <summary>
/// Regression tests for the SeedAudit-based owner-seed guard in EnsureDatabaseAsync.
/// Verifies that once SeedAudit contains "owner_created", subsequent calls to
/// EnsureDatabaseAsync (simulating pod restarts) do NOT reset the owner's password hash.
/// </summary>
public sealed class EnsureDatabaseSeedAuditTests
{
private const string SeedKey = "owner_created";
/// <summary>
/// Creates an in-memory DbContext pre-seeded with an owner user and a SeedAudit row.
/// </summary>
private static async Task<(NexusDbContext db, NexusUser owner, string originalHash)> CreateSeededFixtureAsync()
{
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var db = new NexusDbContext(options);
const string originalPassword = "InitialOwnerPassword123!";
var originalHash = PasswordSecurity.Hash(originalPassword);
var owner = new NexusUser
{
Email = "owner@nexus.internal",
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
DisplayName = "Nexus Owner",
PasswordHash = originalHash,
Role = "owner"
};
db.Users.Add(owner);
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db.SaveChangesAsync();
return (db, owner, originalHash);
}
/// <summary>
/// Simulates the restart guard: if SeedAudit contains owner_created,
/// the owner password hash must not be changed to a newly generated hash.
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash()
{
// Arrange — seed the DB with an owner and a SeedAudit row
var (db, owner, originalHash) = await CreateSeededFixtureAsync();
// Sanity check: password hash starts as expected
Assert.Equal(originalHash, owner.PasswordHash);
Assert.True(PasswordSecurity.Verify("InitialOwnerPassword123!", owner.PasswordHash, out _));
// Act — simulate a password change by the user
const string newPassword = "ChangedOwnerPassword456!";
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
await db.SaveChangesAsync();
// Detach and re-read to confirm the change persisted
db.ChangeTracker.Clear();
var afterChange = await db.Users.FirstAsync(u => u.Id == owner.Id);
Assert.NotEqual(originalHash, afterChange.PasswordHash);
Assert.True(PasswordSecurity.Verify(newPassword, afterChange.PasswordHash, out _));
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterChange.PasswordHash, out _));
// Act — simulate EnsureDatabaseAsync on restart:
// It checks SeedAudit first; if owner_created exists, it returns immediately.
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.True(alreadySeeded, "SeedAudit should contain owner_created after initial seed");
if (alreadySeeded)
{
// EnsureDatabaseAsync returns here — owner is NOT touched
}
// Assert — after the "restart", the password hash must still be the changed one
db.ChangeTracker.Clear();
var afterRestart = await db.Users.FirstAsync(u => u.Id == owner.Id);
Assert.Equal(afterChange.PasswordHash, afterRestart.PasswordHash);
Assert.NotEqual(originalHash, afterRestart.PasswordHash);
Assert.True(PasswordSecurity.Verify(newPassword, afterRestart.PasswordHash, out _),
"Changed password must still work after simulated restart");
Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterRestart.PasswordHash, out _),
"Original seed password must NOT work after simulated restart");
}
/// <summary>
/// Simulates a full restart: creates a completely new DbContext (simulating a new pod),
/// and verifies the SeedAudit guard prevents owner re-seeding.
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword()
{
// Arrange — create and seed the first "instance"
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
Guid ownerId;
string changedHash;
// First "pod run": seed owner + SeedAudit, then change password
await using (var db1 = new NexusDbContext(options))
{
const string initialPassword = "SeedPassword123!";
var owner = new NexusUser
{
Email = "owner@nexus.internal",
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
DisplayName = "Nexus Owner",
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
};
db1.Users.Add(owner);
db1.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db1.SaveChangesAsync();
ownerId = owner.Id;
// Password change
const string newPassword = "NewSecurePassword789!";
owner.PasswordHash = PasswordSecurity.Hash(newPassword);
await db1.SaveChangesAsync();
changedHash = owner.PasswordHash;
}
// Act — second "pod run": new DbContext, simulate EnsureDatabaseAsync
await using (var db2 = new NexusDbContext(options))
{
var alreadySeeded = await db2.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.True(alreadySeeded, "SeedAudit must persist across DbContext instances");
// EnsureDatabaseAsync would return here because alreadySeeded is true
// No user creation or password reset happens
var owner = await db2.Users.FirstAsync(u => u.Id == ownerId);
Assert.Equal(changedHash, owner.PasswordHash);
Assert.True(PasswordSecurity.Verify("NewSecurePassword789!", owner.PasswordHash, out _),
"Changed password must survive a full simulated restart (new DbContext)");
Assert.False(PasswordSecurity.Verify("SeedPassword123!", owner.PasswordHash, out _),
"Seed password must NOT work after restart");
}
}
/// <summary>
/// Verifies that if all users are deleted but SeedAudit still has owner_created,
/// a restart will NOT re-create the owner (the SeedAudit guard is the single
/// source of truth — preventing password drift even if the user table is wiped).
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner()
{
// Arrange
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
// SeedAudit exists from a prior run
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db.SaveChangesAsync();
// Users table is empty (simulating a wiped DB or fresh volume with existing SeedAudit)
var hasUsers = await db.Users.AnyAsync();
Assert.False(hasUsers);
// Act — simulate restart: SeedAudit check
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.True(alreadySeeded);
// EnsureDatabaseAsync returns early because alreadySeeded is true
if (alreadySeeded)
{
// No owner is created
}
// Assert — owner was NOT created (SeedAudit prevents re-seed)
hasUsers = await db.Users.AnyAsync();
Assert.False(hasUsers,
"SeedAudit should prevent owner re-creation even when Users table is empty");
}
/// <summary>
/// Verifies the baseline scenario: without SeedAudit, EnsureDatabaseAsync
/// would proceed to seed a new owner (this is the pre-guard behavior,
/// documented here for completeness).
/// </summary>
[Fact]
public async Task EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner()
{
// Arrange
var options = new DbContextOptionsBuilder<NexusDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
await using var db = new NexusDbContext(options);
// No SeedAudit, no users — this is a fresh DB
var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey);
Assert.False(alreadySeeded);
var hasUsers = await db.Users.AnyAsync();
Assert.False(hasUsers);
// Act — simulate the seed path (what EnsureDatabaseAsync would do when !alreadySeeded && !hasUsers)
if (!alreadySeeded && !hasUsers)
{
// This is what EnsureDatabaseAsync would do: create owner + seed audit
db.Users.Add(new NexusUser
{
Email = "owner@nexus.internal",
NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"),
DisplayName = "Nexus Owner",
PasswordHash = PasswordSecurity.Hash("GeneratedTempPassword"),
Role = "owner"
});
db.SeedAudits.Add(new SeedAudit { Key = SeedKey });
await db.SaveChangesAsync();
}
// Assert — owner now exists
hasUsers = await db.Users.AnyAsync();
Assert.True(hasUsers);
Assert.True(await db.SeedAudits.AnyAsync(s => s.Key == SeedKey));
}
}
+94 -15
View File
@@ -131,7 +131,8 @@ public sealed class StaleTaskRecoveryTests
var recoveryService = new StaleTaskRecoveryService( var recoveryService = new StaleTaskRecoveryService(
taskRepository, taskRepository,
activityRepository, activityRepository,
liveUpdateService); liveUpdateService,
new FakeNotificationService());
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None); var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
@@ -143,7 +144,51 @@ public sealed class StaleTaskRecoveryTests
} }
[Fact] [Fact]
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService() public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Stalled agent task",
State = "In progress",
Source = "iris",
UpdatedAt = stalledTimestamp,
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Fresh in progress",
State = "In progress",
Source = "iris",
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(1, flagged);
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(0, flaggedAgain);
}
[Fact]
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
{ {
var fakeRecoveryService = new FakeStaleTaskRecoveryService(); var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var services = new ServiceCollection(); var services = new ServiceCollection();
@@ -154,20 +199,21 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(), provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{ {
StaleHours = 4, StalledMinutes = 45,
IntervalMinutes = 30 IntervalMinutes = 10
}), }),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance); NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None); var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
Assert.Equal(1, fakeRecoveryService.CallCount); Assert.Equal(1, fakeRecoveryService.FlagCallCount);
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold); Assert.Equal(0, fakeRecoveryService.ResetCallCount);
Assert.Equal(7, resetCount); Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
Assert.Equal(7, flaggedCount);
} }
[Fact] [Fact]
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval() public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
{ {
var fakeRecoveryService = new FakeStaleTaskRecoveryService(); var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -181,8 +227,8 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(), provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{ {
StaleHours = 2, StalledMinutes = 40,
IntervalMinutes = 30 IntervalMinutes = 10
}), }),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance); NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
@@ -191,8 +237,8 @@ public sealed class StaleTaskRecoveryTests
await firstCall.Task.WaitAsync(cts.Token); await firstCall.Task.WaitAsync(cts.Token);
await backgroundService.StopAsync(CancellationToken.None); await backgroundService.StopAsync(CancellationToken.None);
Assert.True(fakeRecoveryService.CallCount >= 1); Assert.True(fakeRecoveryService.FlagCallCount >= 1);
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold); Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
} }
[Fact] [Fact]
@@ -325,19 +371,52 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
{ {
public int CallCount { get; private set; } public int FlagCallCount { get; private set; }
public int ResetCallCount { get; private set; }
public TimeSpan LastThreshold { get; private set; } public TimeSpan LastThreshold { get; private set; }
public Action? OnCall { get; set; } public Action? OnCall { get; set; }
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
FlagCallCount++;
LastThreshold = stalledThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{ {
CallCount++; ResetCallCount++;
LastThreshold = staleThreshold; LastThreshold = staleThreshold;
OnCall?.Invoke(); OnCall?.Invoke();
return Task.FromResult(7); return Task.FromResult(7);
} }
} }
file sealed class FakeNotificationService : INotificationService
{
public List<Notification> Created { get; } = [];
public Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId };
Created.Add(notification);
return Task.FromResult(notification);
}
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
}
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T> file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{ {
public T CurrentValue { get; private set; } = currentValue; public T CurrentValue { get; private set; } = currentValue;
+2 -1
View File
@@ -440,7 +440,8 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
var staleTaskRecoveryService = new StaleTaskRecoveryService( var staleTaskRecoveryService = new StaleTaskRecoveryService(
taskRepository, taskRepository,
activityRepository, activityRepository,
liveUpdateService); liveUpdateService,
notificationService);
var taskService = new TaskService( var taskService = new TaskService(
taskRepository, taskRepository,
+94 -38
View File
@@ -236,38 +236,60 @@ public class DashboardController(
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct); var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20)); using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
while (!ct.IsCancellationRequested) // PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
// Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
// mit einer InvalidOperationException.
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
try
{ {
var readTask = subscription.Reader.ReadAsync(ct).AsTask(); while (!ct.IsCancellationRequested)
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
var completed = await Task.WhenAny(readTask, heartbeatTask);
if (completed == readTask)
{ {
var envelope = await readTask; var completed = await Task.WhenAny(readTask, heartbeatTask);
if (envelope.Type == "notifications.snapshot")
{
var snapshot = envelope.Payload as NotificationSnapshotDto
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
continue;
envelope = envelope with { Payload = snapshot };
}
if (envelope.Type == "tasks.board.snapshot") if (completed == readTask)
{ {
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) }; var envelope = await readTask;
} readTask = subscription.Reader.ReadAsync(ct).AsTask();
await WriteEventAsync("update", new DashboardLiveEventDto( if (envelope.Type == "notifications.snapshot")
envelope, {
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live"))); var snapshot = envelope.Payload as NotificationSnapshotDto
} ?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
else if (await heartbeatTask) if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
{ continue;
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live")); envelope = envelope with { Payload = snapshot };
}
if (envelope.Type == "tasks.board.snapshot")
{
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
}
await WriteEventAsync("update", new DashboardLiveEventDto(
envelope,
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
}
else
{
var ticked = await heartbeatTask;
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
if (!ticked) break;
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
}
} }
} }
catch (OperationCanceledException)
{
// Client hat die Verbindung beendet — normal.
}
catch (System.Threading.Channels.ChannelClosedException)
{
// Subscription serverseitig geschlossen — Stream regulär beenden.
}
} }
[HttpPatch("tasks/{id:guid}/move")] [HttpPatch("tasks/{id:guid}/move")]
@@ -300,6 +322,52 @@ public class DashboardController(
}; };
} }
// ── Review-Aktionen (Bao/Iris) ──
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
[HttpPost("tasks/{id:guid}/approve")]
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
{
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
var result = await taskService.ApproveReviewAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
[HttpPost("tasks/{id:guid}/request-changes")]
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Comment))
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
var currentTask = await taskService.GetByIdAsync(id, ct);
if (currentTask is null)
return NotFound(new { error = "Task not found." });
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
_ => Ok(MapToDto(result.Task!))
};
}
/// <summary> /// <summary>
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim. /// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
/// Falls back to empty string (which authorization helpers reject accordingly). /// Falls back to empty string (which authorization helpers reject accordingly).
@@ -331,19 +399,7 @@ public class DashboardController(
[HttpGet("tasks/{id:guid}/children")] [HttpGet("tasks/{id:guid}/children")]
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct) public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
{ => Ok(await taskService.GetChildTaskDtosAsync(id, ct));
var board = await taskService.GetBoardAsync(ct);
var children = board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done)
.Where(task => task.ParentTaskId == id)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
return Ok(children);
}
[HttpGet("tasks/{id:guid}")] [HttpGet("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct) public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
+7 -1
View File
@@ -99,7 +99,8 @@ public sealed record DashboardTaskDto(
List<DashboardTaskDto>? ChildTasks = null, List<DashboardTaskDto>? ChildTasks = null,
int ChildTaskCount = 0, int ChildTaskCount = 0,
int OpenChildTaskCount = 0, int OpenChildTaskCount = 0,
bool HasVisibleDelegation = false bool HasVisibleDelegation = false,
int DoneChildTaskCount = 0
); );
public sealed record CreateDashboardTaskRequest( public sealed record CreateDashboardTaskRequest(
@@ -183,6 +184,11 @@ public sealed record PostActivityRequest(
string? Type = null string? Type = null
); );
public sealed record RequestChangesRequest(
string Comment,
string? TargetState = null
);
// ── Agent Workflow DTOs ── // ── Agent Workflow DTOs ──
/// <summary> /// <summary>
+1 -1
View File
@@ -235,7 +235,7 @@ public sealed class AgentService(IConfiguration configuration, IAgentRuntime run
private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken) private async Task<IReadOnlyList<AgentConfig>> LoadAgentConfigsAsync(CancellationToken cancellationToken)
{ {
var path = configuration.GetValue<string>("AgentConfigPath") var path = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json"; ?? "/etc/nexus/agents-sanitized.json";
if (!File.Exists(path)) if (!File.Exists(path))
return BuildFallbackConfigs(); return BuildFallbackConfigs();
@@ -2,5 +2,9 @@ namespace Nexus.Api.Services;
public interface IStaleTaskRecoveryService public interface IStaleTaskRecoveryService
{ {
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
} }
+3
View File
@@ -33,9 +33,12 @@ public interface ITaskService
// Task Board // Task Board
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default); Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default); Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default); Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default); Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default); Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default); Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default); Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
+4 -4
View File
@@ -123,7 +123,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
) )
}; };
// Load agent IDs from openclaw.json config // Load agent IDs from sanitized agents config (no secrets)
var agentIds = LoadAgentIdsFromConfig(); var agentIds = LoadAgentIdsFromConfig();
var agents = new List<DashboardAgentInfo>(); var agents = new List<DashboardAgentInfo>();
@@ -227,7 +227,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
} }
/// <summary> /// <summary>
/// Loads agent IDs from the OpenClaw config file (openclaw.json). /// Loads agent IDs from the sanitized agents config (no secrets).
/// Falls back to the known list if the config file is unavailable. /// Falls back to the known list if the config file is unavailable.
/// </summary> /// </summary>
private List<string> LoadAgentIdsFromConfig() private List<string> LoadAgentIdsFromConfig()
@@ -235,7 +235,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
try try
{ {
var configPath = configuration.GetValue<string>("AgentConfigPath") var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json"; ?? "/etc/nexus/agents-sanitized.json";
if (!System.IO.File.Exists(configPath)) if (!System.IO.File.Exists(configPath))
return GetDefaultAgentIds(); return GetDefaultAgentIds();
@@ -1085,7 +1085,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
try try
{ {
var configPath = configuration.GetValue<string>("AgentConfigPath") var configPath = configuration.GetValue<string>("AgentConfigPath")
?? "/home/node/.openclaw/openclaw.json"; ?? "/etc/nexus/agents-sanitized.json";
if (!System.IO.File.Exists(configPath)) if (!System.IO.File.Exists(configPath))
return GetDefaultModels(); return GetDefaultModels();
@@ -13,9 +13,9 @@ public sealed class StaleTaskRecoveryBackgroundService(
{ {
try try
{ {
var resetCount = await RunRecoveryOnceAsync(stoppingToken); var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
if (resetCount > 0) if (flaggedCount > 0)
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount); logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
} }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{ {
@@ -37,10 +37,10 @@ public sealed class StaleTaskRecoveryBackgroundService(
} }
} }
public async Task<int> RunRecoveryOnceAsync(CancellationToken ct = default) public async Task<int> RunWatchdogOnceAsync(CancellationToken ct = default)
{ {
await using var scope = scopeFactory.CreateAsyncScope(); await using var scope = scopeFactory.CreateAsyncScope();
var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>(); var recoveryService = scope.ServiceProvider.GetRequiredService<IStaleTaskRecoveryService>();
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct); return await recoveryService.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
} }
} }
+9 -1
View File
@@ -4,8 +4,16 @@ public sealed class StaleTaskRecoveryOptions
{ {
public const string SectionName = "TaskRecovery"; public const string SectionName = "TaskRecovery";
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
public int StalledMinutes { get; set; } = 40;
/// <summary>Prüfintervall des Watchdogs.</summary>
public int IntervalMinutes { get; set; } = 10;
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
public int StaleHours { get; set; } = 2; public int StaleHours { get; set; } = 2;
public int IntervalMinutes { get; set; } = 30;
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours)); public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
+79 -97
View File
@@ -7,8 +7,81 @@ namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryService( public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository, ITaskRepository taskRepository,
IActivityRepository activityRepository, IActivityRepository activityRepository,
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService ILiveUpdateService liveUpdateService,
INotificationService notificationService) : IStaleTaskRecoveryService
{ {
private const string StalledActivityType = "stalled";
/// <summary>
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
/// </summary>
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var threshold = now - stalledThreshold;
var allTasks = await taskRepository.GetAllAsync(ct);
var inProgress = allTasks
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
.ToList();
if (inProgress.Count == 0)
return 0;
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
var activityByTask = activities
.Where(a => a.TaskId.HasValue)
.GroupBy(a => a.TaskId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
var flaggedCount = 0;
foreach (var task in inProgress)
{
activityByTask.TryGetValue(task.Id, out var taskActivity);
var latest = taskActivity?.FirstOrDefault();
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
if (lastProgressAt >= threshold)
continue;
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
continue;
var silentFor = now - lastProgressAt;
await activityRepository.AddAsync(new ActivityEvent
{
Type = StalledActivityType,
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
TaskId = task.Id
}, ct);
await notificationService.CreateAsync(
"task_stalled",
$"Task hängt: {task.Title}",
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
"iris",
task.Id,
ct);
flaggedCount++;
}
if (flaggedCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return flaggedCount;
}
/// <summary>
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
/// </summary>
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{ {
var threshold = DateTimeOffset.UtcNow - staleThreshold; var threshold = DateTimeOffset.UtcNow - staleThreshold;
@@ -80,88 +153,8 @@ public sealed class StaleTaskRecoveryService(
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct) private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
{ {
var allTasks = await taskRepository.GetAllAsync(ct); var allTasks = await taskRepository.GetAllAsync(ct);
var taskIds = allTasks.Select(task => task.Id).ToList(); var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct); return TaskService.BuildMasterBoard(allTasks, activity);
var backlog = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
foreach (var task in allTasks)
{
var dto = MapToDtoWithChildren(task, allTasks, activity);
switch (task.State.ToLowerInvariant())
{
case "backlog": backlog.Add(dto); break;
case "in progress": inProgress.Add(dto); break;
case "review": review.Add(dto); break;
case "blocked": blocked.Add(dto); break;
case "done": done.Add(dto); break;
default: backlog.Add(dto); break;
}
}
backlog.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(backlog, inProgress, review, blocked, done);
}
private static DashboardTaskDto MapToDtoWithChildren(
WorkTask task,
IReadOnlyList<WorkTask> allTasks,
IEnumerable<ActivityEvent> activity)
{
var childTasks = allTasks
.Where(candidate => candidate.ParentTaskId == task.Id)
.OrderByDescending(candidate => candidate.UpdatedAt)
.ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList();
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity);
return dto with
{
ChildTasks = childDtos,
ChildTaskCount = childDtos.Count,
OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
};
}
private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable<ActivityEvent> activity)
{
var last = activity
.Where(entry => entry.TaskId == task.Id)
.OrderByDescending(entry => entry.CreatedAt)
.FirstOrDefault();
return new DashboardTaskDto(
task.Id,
task.Title,
task.Detail,
task.Source,
task.State,
task.Priority,
task.AssignedTo,
task.ParentTaskId,
task.DueDate,
task.CreatedAt,
task.UpdatedAt,
task.IsAgentTask,
task.ExpectedFrom,
last?.Message,
last?.CreatedAt,
null,
0,
0,
task.ParentTaskId.HasValue || task.IsAgentTask);
} }
private static string BuildActivityMessage( private static string BuildActivityMessage(
@@ -190,20 +183,9 @@ public sealed class StaleTaskRecoveryService(
} }
private static string FormatDuration(TimeSpan duration) private static string FormatDuration(TimeSpan duration)
=> duration.ToString(@"dd\.hh\:mm\:ss");
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
{ {
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority)); if (duration.TotalHours >= 1)
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt); return $"{(int)duration.TotalHours}h {duration.Minutes}min";
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
} }
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
} }
+1 -14
View File
@@ -214,13 +214,7 @@ public sealed class TaskBridgeService(
public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync( public async Task<IReadOnlyList<DashboardTaskDto>> GetChildTasksAsync(
Guid parentTaskId, CancellationToken ct = default) Guid parentTaskId, CancellationToken ct = default)
{ => await taskService.GetChildTaskDtosAsync(parentTaskId, ct);
var board = await taskService.GetBoardAsync(ct);
return FlattenBoard(board)
.Where(task => task.ParentTaskId == parentTaskId)
.OrderByDescending(task => task.UpdatedAt)
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync( public async Task<List<ActivityEvent>> GetTaskActivityAsync(
Guid taskId, CancellationToken ct = default) Guid taskId, CancellationToken ct = default)
@@ -250,13 +244,6 @@ public sealed class TaskBridgeService(
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors); return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
} }
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
=> board.Offen
.Concat(board.InProgress)
.Concat(board.Review)
.Concat(board.Blocked)
.Concat(board.Done);
private static DashboardTaskDto MapToDto(WorkTask t) => new( private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt, t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
+111 -5
View File
@@ -422,6 +422,19 @@ public sealed class TaskService(
{ {
var all = (await taskRepo.GetAllAsync(ct)).ToList(); var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct); var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
return BuildMasterBoard(all, activity);
}
/// <summary>
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
/// damit nichts unsichtbar wird.
/// </summary>
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
{
var ids = all.Select(t => t.Id).ToHashSet();
var offen = new List<DashboardTaskDto>(); var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>(); var inProgress = new List<DashboardTaskDto>();
@@ -431,7 +444,10 @@ public sealed class TaskService(
foreach (var task in all) foreach (var task in all)
{ {
var dto = MapToDtoWithChildren(task, all, activity); var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
if (!isMaster) continue;
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
switch (task.State.ToLowerInvariant()) switch (task.State.ToLowerInvariant())
{ {
case "backlog": offen.Add(dto); break; case "backlog": offen.Add(dto); break;
@@ -490,6 +506,78 @@ public sealed class TaskService(
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct); return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
} }
/// <summary>
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
/// </summary>
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.ExpectedFrom = null;
return await UpdateTaskStatusInternalAsync(
task,
TaskStateHelper.ToStateString(TaskState.Done),
caller,
"review",
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
ct);
}
/// <summary>
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
/// </summary>
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var caller = ResolveCaller();
if (!TaskStateHelper.CanChangeState(caller, task))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
?? TaskStateHelper.ToStateString(TaskState.InProgress);
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
target = TaskStateHelper.ToStateString(TaskState.InProgress);
var trimmed = comment.Trim();
await activityRepo.AddAsync(new ActivityEvent
{
Type = "review_changes_requested",
Message = $"Änderung angefordert von {caller}: {trimmed}",
TaskId = task.Id
}, ct);
task.ExpectedFrom = "iris";
var result = await UpdateTaskStatusInternalAsync(
task, target, caller, "review",
$"Review zurückgegeben von {caller} → {target}", ct);
await notificationService.CreateAsync(
"task_changes_requested",
$"Änderung angefordert: {task.Title}",
trimmed,
"iris",
task.Id,
ct);
return result;
}
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default) public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
{ {
var normalizedHours = Math.Max(1, staleHours); var normalizedHours = Math.Max(1, staleHours);
@@ -507,28 +595,46 @@ public sealed class TaskService(
.ToList(); .ToList();
} }
/// <summary>
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
/// </summary>
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
{
var all = (await taskRepo.GetAllAsync(ct)).ToList();
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
return all.Where(t => t.ParentTaskId == parentId)
.OrderByDescending(t => t.UpdatedAt)
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
.ToList();
}
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default) public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
{ {
var all = await activityRepo.GetRecentAsync(100, ct); var all = await activityRepo.GetRecentAsync(100, ct);
return all.Where(e => e.TaskId == taskId).ToList(); return all.Where(e => e.TaskId == taskId).ToList();
} }
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity) private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
{ {
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id) var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
.OrderByDescending(t => t.UpdatedAt) .OrderByDescending(t => t.UpdatedAt)
.ToList(); .ToList();
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList(); // includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
var childDtos = includeChildren
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
: null;
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase)); var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
var dto = MapToDtoWithActivity(task, activity, allTasks); var dto = MapToDtoWithActivity(task, activity, allTasks);
return dto with return dto with
{ {
ChildTasks = childDtos, ChildTasks = childDtos,
ChildTaskCount = childDtos.Count, ChildTaskCount = childTasks.Count,
OpenChildTaskCount = openChildTaskCount, OpenChildTaskCount = openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask DoneChildTaskCount = childTasks.Count - openChildTaskCount,
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
}; };
} }
+3 -2
View File
@@ -23,8 +23,9 @@
"RefreshTokenExpirationDays": 7 "RefreshTokenExpirationDays": 7
}, },
"TaskRecovery": { "TaskRecovery": {
"StaleHours": 2, "StalledMinutes": 40,
"IntervalMinutes": 30 "IntervalMinutes": 10,
"StaleHours": 2
}, },
"AllowedHosts": "*" "AllowedHosts": "*"
} }
+2 -1
View File
@@ -64,6 +64,7 @@ services:
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-} Admin__ResetToken: ${Admin__ResetToken:-}
NexusApiKey: ${NEXUS_API_KEY:-} NexusApiKey: ${NEXUS_API_KEY:-}
AgentConfigPath: /etc/nexus/agents-sanitized.json
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
depends_on: depends_on:
@@ -77,7 +78,7 @@ services:
retries: 3 retries: 3
start_period: 15s start_period: 15s
volumes: volumes:
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro - /home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json:/etc/nexus/agents-sanitized.json:ro
- /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris - /home/projekte_bao/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer - /home/projekte_bao/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer - /home/projekte_bao/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
+9
View File
@@ -5,6 +5,15 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Kompression für Bundle + API-JSON (Board-Payload ~100 KB → wenige KB).
# text/event-stream bewusst NICHT in gzip_types: gzip würde den SSE-Stream puffern.
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types application/json application/javascript text/css text/javascript image/svg+xml;
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
add_header Referrer-Policy "no-referrer" always; add_header Referrer-Policy "no-referrer" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
+8 -150
View File
@@ -1,156 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' /**
import { Activity } from '@lucide/vue' * App — nur noch Router-Einstieg + Toasts.
import { RouterView, useRoute, useRouter } from 'vue-router' * Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
import { useOperationsStore } from './stores/operations' * und umschließt alle Seiten außer dem Login.
import { useAuthStore } from './stores/auth' */
import AppSidebar from './components/layout/AppSidebar.vue' import { RouterView } from 'vue-router'
import AppHeader from './components/layout/AppHeader.vue'
import GalaxyBackground from './components/background/GalaxyBackground.vue'
import ModuleView from './components/ModuleView.vue'
import ToastContainer from './components/ui/ToastContainer.vue' import ToastContainer from './components/ui/ToastContainer.vue'
const store = useOperationsStore()
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const activeView = computed(() => {
if (route.name === 'Settings') return 'Settings'
if (route.name === 'ProjectDetail') return 'ProjectDetail'
return String(route.name ?? 'Dashboard')
})
const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
}
const navigate = (label: string) => {
mobileNavOpen.value = false
return router.push(routePaths[label] ?? '/dashboard')
}
const mobileNavOpen = ref(false)
const standaloneViews = computed(() => {
if (route.name === 'Dashboard') return true
if (route.meta?.standalone) return true
return false
})
onMounted(() => {
if (auth.isAuthenticated) store.refresh()
})
</script> </script>
<template> <template>
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" /> <RouterView />
<div v-else class="shell"> <ToastContainer />
<GalaxyBackground />
<AppSidebar
:active-view="activeView"
:mobile-nav-open="mobileNavOpen"
:queued-tasks="store.snapshot.metrics.queuedTasks"
:incidents="store.snapshot.metrics.incidents"
@navigate="navigate"
/>
<main>
<AppHeader
:connected="store.connected"
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
/>
<section class="content">
<RouterView v-if="standaloneViews" />
<template v-else>
<div class="page-heading">
<div>
<span class="eyebrow">MISSION CONTROL</span>
<h1>{{ activeView }}</h1>
<p>System overview and operational intelligence across Noveria.</p>
</div>
<button class="refresh" @click="store.refresh()">
<Activity :size="15" :class="{ spin: store.loading }" />
Refresh
</button>
</div>
<ModuleView
:view="activeView"
:snapshot="store.snapshot"
:routing="store.routing"
@create-project="store.createProject"
@create-task="store.createTask"
@update-task-state="store.updateTaskState"
/>
</template>
</section>
</main>
<ToastContainer />
</div>
</template> </template>
<style scoped>
.shell {
display: flex;
height: 100vh;
overflow: hidden;
}
main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
z-index: 1;
}
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
gap: 12px;
}
.page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
.eyebrow {
font-size: 8.5px;
font-weight: 700;
letter-spacing: .12em;
color: var(--nx-accent);
text-transform: uppercase;
}
.refresh {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding: 6px 11px;
border: 1px solid var(--nx-line);
border-radius: 6px;
background: transparent;
color: var(--nx-text-dim);
font-size: 9px;
cursor: pointer;
transition: background .15s;
}
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 860px) {
.kanban { grid-template-columns: 1fr; }
}
</style>
+72
View File
@@ -37,6 +37,14 @@
--st-block: #fb7185; --st-block: #fb7185;
--st-idle: #6b6796; --st-idle: #6b6796;
/* ── Agent/Role Semantic Colors ───────────────────── */
--clr-iris: #c084fc;
--clr-bao: #60a5fa;
--clr-agent: #6ee7b7;
--clr-review: #fdba74;
--clr-stale: #fda4af;
--clr-other: #fb923c;
/* ── Glows ────────────────────────────────────────── */ /* ── Glows ────────────────────────────────────────── */
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55); --glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65); --glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
@@ -78,6 +86,20 @@
--text-secondary: var(--tx-2); --text-secondary: var(--tx-2);
--text-muted: var(--tx-3); --text-muted: var(--tx-3);
--text-dim: var(--tx-3); --text-dim: var(--tx-3);
/* ── State Pill Colors ───────────────────────────── */
--pill-backlog: #fde68a;
--pill-progress: #86efac;
--pill-done: #86efac;
--pill-review: #fdba74;
--pill-blocked: #fda4af;
/* Agent semantic colors (legacy aliases) */
--nx-iris: var(--clr-iris);
--nx-bao: var(--clr-bao);
--nx-agent: var(--clr-agent);
--nx-review: var(--clr-review);
--nx-stale: var(--clr-stale);
} }
/* ── Glass card utility ────────────────────────────── */ /* ── Glass card utility ────────────────────────────── */
@@ -133,3 +155,53 @@
/* ── Typography helpers ────────────────────────────── */ /* ── Typography helpers ────────────────────────────── */
.font-display { font-family: 'Space Grotesk', sans-serif; } .font-display { font-family: 'Space Grotesk', sans-serif; }
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; } .font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
/* ── Button Variants (nexus-token-basiert) ──────────── */
.nexus-btn-gradient {
border: none;
background: var(--grad);
color: var(--tx);
box-shadow: var(--glow-purple);
border-radius: var(--r-sm);
font-weight: 600;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: opacity .15s, transform .15s;
}
.nexus-btn-gradient:hover { opacity: .85; transform: translateY(-1px); }
.nexus-btn-gradient:active { transform: translateY(0); }
.nexus-btn-icon {
display: grid;
place-items: center;
border: 1px solid var(--line);
background: transparent;
color: var(--tx-2);
border-radius: var(--r-sm);
cursor: pointer;
transition: background .15s, color .15s;
}
.nexus-btn-icon:hover { background: rgba(124,108,255,.10); color: var(--tx); }
.nexus-btn-ghost-subtle {
border: 1px solid var(--line);
background: transparent;
color: var(--tx-2);
border-radius: var(--r-sm);
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: background .15s, color .15s;
}
.nexus-btn-ghost-subtle:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.nexus-btn-danger {
border: 1px solid rgba(251,113,133,.3);
background: rgba(251,113,133,.12);
color: var(--st-block);
border-radius: var(--r-sm);
font-weight: 600;
font-family: 'Manrope', sans-serif;
cursor: pointer;
transition: background .15s;
}
.nexus-btn-danger:hover { background: rgba(251,113,133,.22); }
-741
View File
@@ -1,741 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
import { TASK_STATES } from '../types'
import { apiFetch } from '../services/api'
import { useAuthStore } from '../stores/auth'
import { useOperationsStore, type PendingApprovalTask } from '../stores/operations'
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
const emit = defineEmits<{
createProject: [name: string]
createTask: [title: string, priority: string]
updateTaskState: [id: string, state: string]
}>()
const store = useOperationsStore()
const auth = useAuthStore()
const agents = ref<AgentInfo[]>([])
const agentsLoading = ref(false)
const pendingApprovals = ref<PendingApprovalTask[]>([])
const pendingApprovalsLoading = ref(false)
const pendingApprovalsError = ref('')
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
async function loadAgents() {
if (agentsLoading.value) return
agentsLoading.value = true
agents.value = await store.fetchAgents()
agentsLoading.value = false
}
async function loadPendingApprovals() {
if (!canModerateApprovals.value) {
pendingApprovals.value = []
pendingApprovalsError.value = ''
return
}
pendingApprovalsLoading.value = true
pendingApprovalsError.value = ''
try {
pendingApprovals.value = await store.fetchPendingApprovals()
} catch (e) {
pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals'
} finally {
pendingApprovalsLoading.value = false
}
}
onMounted(() => {
if (props.view === 'Agents') loadAgents()
if (props.view === 'Task Board') void loadPendingApprovals()
})
watch(() => props.view, (v) => {
if (v === 'Agents') loadAgents()
if (v === 'Task Board') void loadPendingApprovals()
})
watch(canModerateApprovals, (value) => {
if (props.view !== 'Task Board') return
if (value) {
void loadPendingApprovals()
return
}
pendingApprovals.value = []
pendingApprovalsError.value = ''
})
const newProject = ref('')
const newTask = ref('')
const message = ref('')
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
const chatPending = ref(false)
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
localStorage.setItem('nexus-conversation-id', conversationId.value)
// Task editing state
const editingTaskId = ref<string | null>(null)
// Task approval / rejection state
const approvingTaskId = ref<string | null>(null)
const taskActionError = ref('')
async function handleApproveTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.approveTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
} finally {
approvingTaskId.value = null
}
}
async function handleRejectTask(id: string) {
approvingTaskId.value = id
taskActionError.value = ''
try {
await store.rejectTask(id)
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
} catch (e) {
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
} finally {
approvingTaskId.value = null
}
}
// Task deletion state
const deletingTaskId = ref<string | null>(null)
const deleteError = ref('')
async function confirmDeleteTask(id: string) {
deleteError.value = ''
try {
await store.deleteTask(id)
deletingTaskId.value = null
} catch (e) {
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
}
}
function cancelDeleteTask() {
deletingTaskId.value = null
deleteError.value = ''
}
const editTaskTitle = ref('')
const editTaskPriority = ref('')
const editTaskProjectId = ref<string | null>(null)
// Activity filtering and pagination
const activityTypeFilter = ref('')
const activitySort = ref('newest')
const activityPage = ref(1)
const activityPageSize = 20
const activityTotalPages = ref(1)
const activityTotalCount = ref(0)
const columns = computed(() =>
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
const availableTypes = computed(() => {
const types = new Set(props.snapshot.activity.map(e => e.type))
return Array.from(types)
})
const filteredActivity = computed(() => {
let items = [...props.snapshot.activity]
if (activityTypeFilter.value) {
items = items.filter(e => e.type === activityTypeFilter.value)
}
if (activitySort.value === 'oldest') {
items.reverse()
}
const total = items.length
activityTotalCount.value = total
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
const start = (activityPage.value - 1) * activityPageSize
return items.slice(start, start + activityPageSize)
})
watch(activityTypeFilter, () => { activityPage.value = 1 })
watch(activitySort, () => { activityPage.value = 1 })
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
editingTaskId.value = task.id
editTaskTitle.value = task.title
editTaskPriority.value = task.priority
editTaskProjectId.value = task.projectId ?? null
}
async function saveEditTask(id: string) {
try {
await store.updateTask(id, {
title: editTaskTitle.value.trim() || undefined,
priority: editTaskPriority.value || undefined,
projectId: editTaskProjectId.value || undefined,
})
editingTaskId.value = null
} catch (e) {
console.error('Failed to update task', e)
}
}
function cancelEditTask() {
editingTaskId.value = null
}
async function sendMessage() {
const value = message.value.trim()
if (!value || chatPending.value) return
chatMessages.value.push({ role: 'owner', content: value })
message.value = ''
chatPending.value = true
try {
const response = await apiFetch('/api/v1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
})
const payload = await response.json()
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
conversationId.value = payload.conversationId
localStorage.setItem('nexus-conversation-id', payload.conversationId)
chatMessages.value.push({ role: 'iris', content: payload.content })
} catch (error) {
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
} finally {
chatPending.value = false
}
}
</script>
<template>
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
<div v-if="view === 'Projects'" class="module-grid">
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
</article>
</div>
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
<header class="approval-strip-head">
<div>
<span class="kicker">Owner approvals</span>
<h3>Pending approvals</h3>
</div>
<span class="badge">{{ pendingApprovals.length }}</span>
</header>
<p v-if="pendingApprovalsLoading" class="approval-strip-note">Loading owner approval queue</p>
<p v-else-if="pendingApprovalsError" class="approval-strip-note error">{{ pendingApprovalsError }}</p>
<p v-else-if="!pendingApprovals.length" class="approval-strip-note">No tasks are waiting for Bao approval.</p>
<div v-else class="approval-list">
<article v-for="task in pendingApprovals" :key="task.id" class="approval-card">
<div>
<strong>{{ task.title }}</strong>
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
</div>
<div class="approval-actions">
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
</div>
</article>
</div>
<p v-if="taskActionError" class="approval-strip-note error">{{ taskActionError }}</p>
</section>
<div v-if="view === 'Task Board'" class="kanban">
<section v-for="column in columns" :key="column.name" class="kanban-column">
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
<article v-for="task in column.items" :key="task.id" class="task-card">
<template v-if="editingTaskId === task.id">
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
<div class="task-edit-row">
<select v-model="editTaskPriority">
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Normal">Normal</option>
<option value="Low">Low</option>
</select>
<select v-model="editTaskProjectId">
<option :value="null">No project</option>
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="task-edit-actions">
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
</div>
</template>
<template v-else>
<div class="task-card-head">
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
<div class="task-card-actions">
<template v-if="task.state === 'In progress' && canModerateApprovals">
<button
class="task-approve-btn"
title="Approve"
:disabled="approvingTaskId === task.id"
@click="handleApproveTask(task.id)"
><CheckCircle2 :size="13" /></button>
<button
class="task-reject-btn"
title="Reject"
:disabled="approvingTaskId === task.id"
@click="handleRejectTask(task.id)"
><X :size="13" /></button>
</template>
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
<button
v-if="task.state === 'Done' || task.state === 'Backlog'"
class="task-delete-btn"
title="Delete task"
@click="deletingTaskId = task.id; deleteError = ''"
><Trash2 :size="12" /></button>
</div>
</div>
<h3>{{ task.title }}</h3>
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
</select>
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
</template>
</article>
<div v-if="!column.items.length" class="empty-state">No tasks</div>
</section>
</div>
<div v-else-if="view === 'Agents'" class="module-grid">
<div v-if="agentsLoading" class="loading-agents">Loading agents</div>
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
<Zap v-else :size="22" />
</div>
<div>
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
<h3>{{ agent.name }}</h3>
<p>{{ agent.description || agent.model }}</p>
</div>
<div class="agent-status-group">
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
</div>
</article>
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
</div>
<div v-else-if="view === 'Models'" class="module-list panel">
<div v-for="model in routing" :key="model.model" class="model-detail">
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
</div>
</div>
<div v-else-if="view === 'Activity'" class="activity-panel panel">
<div class="activity-filters">
<div class="filter-group">
<label>Type</label>
<select v-model="activityTypeFilter">
<option value="">All types</option>
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="filter-group">
<label>Sort</label>
<select v-model="activitySort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</div>
</div>
<div class="timeline">
<article v-for="event in filteredActivity" :key="event.message + event.at">
<div :class="['timeline-icon', event.type]">
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
<ShieldAlert v-else :size="15" />
</div>
<div>
<span class="kicker">{{ event.type }}</span>
<h3>{{ event.message }}</h3>
<p>{{ new Date(event.at).toLocaleString() }}</p>
</div>
</article>
</div>
<div v-if="activityTotalPages > 1" class="activity-pagination">
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
</div>
</div>
<div v-else-if="view === 'Settings'" class="settings-redirect">
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
</div>
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
</div>
<!-- Task deletion confirmation dialog -->
<Teleport to="body">
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
<div class="delete-dialog">
<h3>Delete Task?</h3>
<p>This action cannot be undone. The task will be permanently removed.</p>
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
<div class="delete-actions">
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.approval-strip {
margin: 0 0 18px;
padding: 14px 16px;
border: 1px solid var(--line, #1e2030);
border-radius: 14px;
background: rgba(255,255,255,.025);
}
.approval-strip-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.approval-strip-head h3 {
margin: 2px 0 0;
}
.approval-strip-note {
margin: 10px 0 0;
color: #8e96a8;
}
.approval-strip-note.error {
color: #e16e75;
}
.approval-list {
display: grid;
gap: 10px;
margin-top: 12px;
}
.approval-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid rgba(255,255,255,.06);
border-radius: 12px;
background: rgba(8, 10, 18, .35);
}
.approval-card p {
margin: 4px 0 0;
color: #8e96a8;
font-size: 12px;
}
.approval-actions {
display: flex;
gap: 8px;
}
.task-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.task-card-actions {
display: flex;
gap: 0.15rem;
align-items: center;
}
.task-edit-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s;
}
.task-card:hover .task-edit-btn {
opacity: 1;
}
.task-edit-btn:hover {
color: var(--nx-accent);
}
.task-delete-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-delete-btn {
opacity: 1;
}
.task-delete-btn:hover {
color: var(--danger, #e74c3c);
}
.task-edit-input {
width: 100%;
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9rem;
color: var(--text-primary);
margin-bottom: 0.4rem;
}
.task-edit-row {
display: flex;
gap: 0.35rem;
margin-bottom: 0.4rem;
}
.task-edit-row select {
flex: 1;
padding: 0.25rem 0.4rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8rem;
color: var(--text-primary);
}
.task-edit-actions {
display: flex;
gap: 0.35rem;
}
.task-edit-save {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.task-edit-cancel {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--surface-raised);
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.78rem;
cursor: pointer;
}
.activity-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.activity-filters {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.4rem;
}
.filter-group label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
}
.filter-group select {
padding: 0.35rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.85rem;
color: var(--text-primary);
}
.activity-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.activity-pagination button {
display: flex;
align-items: center;
padding: 0.3rem 0.5rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.activity-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.activity-pagination span {
font-size: 0.85rem;
color: var(--text-secondary);
}
.project-card {
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.project-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.settings-redirect {
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.settings-redirect a {
color: var(--nx-accent);
text-decoration: none;
}
.settings-redirect a:hover {
text-decoration: underline;
}
/* Task deletion confirmation */
.delete-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.delete-dialog {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
max-width: 380px;
width: 90%;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.delete-dialog h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.delete-dialog p {
margin: 0 0 1rem;
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.4;
}
.delete-error {
color: var(--danger, #e74c3c) !important;
font-weight: 600;
}
.delete-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.delete-cancel {
padding: 0.45rem 1rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.85rem;
}
.delete-confirm {
padding: 0.45rem 1rem;
background: var(--danger, #e74c3c);
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
}
.delete-confirm:hover {
opacity: 0.9;
}
/* Agent card enhancements */
.agent-status-group {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.3rem;
}
.agent-model-tag {
font-size: 0.7rem;
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.1rem 0.35rem;
color: var(--text-muted);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loading-agents {
grid-column: 1 / -1;
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
/* Task approve/reject buttons */
.task-approve-btn,
.task-reject-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.15rem;
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s;
}
.task-card:hover .task-approve-btn,
.task-card:hover .task-reject-btn {
opacity: 1;
}
.task-approve-btn {
color: var(--success, #27ae60);
}
.task-approve-btn:hover {
color: var(--success, #27ae60);
filter: brightness(1.2);
}
.task-reject-btn {
color: var(--warning, #f39c12);
}
.task-reject-btn:hover {
color: var(--danger, #e74c3c);
}
.task-approve-btn:disabled,
.task-reject-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
/**
* BoardCard — eine Master-Task-Karte im Board.
*
* Zeigt nur Top-Level-Tasks; Child-Tasks der Agenten leben ausklappbar
* IN der Karte (gruppiert nach Agent) statt als eigene Spalten-Karten —
* so bleibt das Board übersichtlich, auch wenn Iris groß zerlegt.
*
* Ball = wer gerade dran ist. Stalled = In-Bearbeitung ohne Aktivität
* seit der Schwelle (Watchdog meldet parallel an Iris).
*/
import { computed, ref } from 'vue'
import { ChevronRight, Check, RotateCcw, Bot, User, AlertTriangle } from '@lucide/vue'
import type { DashboardTaskDto } from '../../stores/tasks'
import { TASK_AGENT_LABELS } from '../../constants/agentPool'
const props = defineProps<{
task: DashboardTaskDto
column: string
canReview: boolean
stallThresholdMin: number
}>()
const emit = defineEmits<{
open: [id: string]
approve: [id: string]
requestChanges: [task: DashboardTaskDto]
dragstart: [e: DragEvent, id: string]
dragend: [e: DragEvent]
}>()
const expanded = ref(false)
const children = computed(() => props.task.childTasks ?? [])
const hasChildren = computed(() => children.value.length > 0)
const totalChildren = computed(() => props.task.childTaskCount ?? children.value.length)
const doneChildren = computed(() =>
props.task.doneChildTaskCount ?? children.value.filter(c => c.state === 'Done').length
)
const progressPct = computed(() =>
totalChildren.value > 0 ? Math.round((doneChildren.value / totalChildren.value) * 100) : 0
)
/* ── Ball: wer ist dran ───────────────────────────── */
const ballAgent = computed(() => {
const s = props.task.state.toLowerCase()
if (s === 'review') return 'bao'
if (s === 'done') return null
if (s === 'backlog') return props.task.expectedFrom || 'iris'
return props.task.expectedFrom || props.task.assignedTo || 'iris'
})
function agentLabel(id?: string | null): string {
if (!id) return '—'
return TASK_AGENT_LABELS[id.toLowerCase()] ?? id
}
function agentClass(id?: string | null): string {
const lower = (id ?? '').toLowerCase()
if (lower === 'iris') return 'is-iris'
if (lower === 'bao') return 'is-bao'
return 'is-agent'
}
/* ── Stalled-Erkennung (rein aus Aktivitätszeit) ──── */
function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
function isStalled(t: DashboardTaskDto): boolean {
if (t.state.toLowerCase() !== 'in progress') return false
return minutesSince(t.lastActivityAt ?? t.updatedAt) > props.stallThresholdMin
}
const masterStalled = computed(() => {
if (hasChildren.value) return children.value.some(isStalled)
return isStalled(props.task)
})
/* ── Child-Gruppierung nach Agent ─────────────────── */
const childrenByAgent = computed(() => {
const groups = new Map<string, DashboardTaskDto[]>()
for (const child of children.value) {
const key = child.assignedTo || 'unassigned'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(child)
}
return [...groups.entries()].map(([agent, tasks]) => ({ agent, tasks }))
})
const assigneeInitials = computed(() => {
const unique = new Set(children.value.map(c => c.assignedTo).filter(Boolean) as string[])
if (!unique.size && props.task.assignedTo) unique.add(props.task.assignedTo)
return [...unique].slice(0, 4).map(a => agentLabel(a).replace(/^[^\w]+/, '').slice(0, 2).toUpperCase())
})
function priorityLabel(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'High'
if (lower === 'low' || lower === 'minor') return 'Low'
return 'Med'
}
function priorityClass(p: string): string {
const lower = p.toLowerCase()
if (lower === 'high' || lower === 'critical' || lower === 'urgent') return 'prio-high'
if (lower === 'low' || lower === 'minor') return 'prio-low'
return 'prio-med'
}
function childStateLabel(state: string): string {
const map: Record<string, string> = {
'backlog': 'Offen', 'in progress': 'Aktiv', 'review': 'Review', 'blocked': 'Blockiert', 'done': 'Fertig',
}
return map[state.toLowerCase()] ?? state
}
function childStateClass(state: string): string {
const s = state.toLowerCase()
if (s === 'done') return 'cs-done'
if (s === 'blocked') return 'cs-blocked'
if (s === 'review') return 'cs-review'
if (s === 'in progress') return 'cs-active'
return 'cs-backlog'
}
function relTime(date?: string | null): string {
if (!date) return 'keine Aktivität'
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const h = Math.round(mins / 60)
if (h < 24) return `vor ${h} h`
return `vor ${Math.round(h / 24)} d`
}
function toggleExpand(e: MouseEvent) {
e.stopPropagation()
expanded.value = !expanded.value
}
</script>
<template>
<div
class="mcard"
:class="{ 'mcard-blocked': column === 'blocked', 'mcard-stalled': masterStalled }"
draggable="true"
@click="emit('open', task.id)"
@dragstart="emit('dragstart', $event, task.id)"
@dragend="emit('dragend', $event)"
>
<!-- Kopf: Ball + Priorität + Stalled -->
<div class="mcard-top">
<span v-if="ballAgent" class="ball" :class="agentClass(ballAgent)" :title="'Ball bei ' + agentLabel(ballAgent)">
<Bot v-if="ballAgent === 'iris'" :size="11" />
<User v-else-if="ballAgent === 'bao'" :size="11" />
<span v-else class="ball-dot"></span>
{{ agentLabel(ballAgent) }}
</span>
<span class="prio" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
<span v-if="masterStalled" class="stalled-chip" title="Keine Aktivität seit der Schwelle — Iris benachrichtigt">
<AlertTriangle :size="11" /> hängt
</span>
</div>
<!-- Titel -->
<div class="mcard-title">{{ task.title }}</div>
<!-- Fortschritt aus Children -->
<div v-if="hasChildren" class="mcard-progress">
<div class="progress-row">
<button class="expand-btn" :class="{ open: expanded }" @click="toggleExpand" :aria-label="expanded ? 'Einklappen' : 'Ausklappen'">
<ChevronRight :size="14" />
</button>
<span class="progress-text">{{ doneChildren }}/{{ totalChildren }} Teilaufgaben</span>
<div class="avatars">
<span v-for="(ini, i) in assigneeInitials" :key="i" class="avatar-mini">{{ ini }}</span>
</div>
</div>
<div class="progress-track"><div class="progress-fill" :style="{ width: progressPct + '%' }"></div></div>
</div>
<div v-else-if="task.detail" class="mcard-preview">{{ task.detail }}</div>
<!-- Ausgeklappte Children, gruppiert nach Agent -->
<div v-if="expanded && hasChildren" class="children" @click.stop>
<div v-for="group in childrenByAgent" :key="group.agent" class="child-group">
<div class="child-group-head">
<span class="child-agent" :class="agentClass(group.agent)">{{ agentLabel(group.agent) }}</span>
<span class="child-group-count">{{ group.tasks.length }}</span>
</div>
<button
v-for="child in group.tasks"
:key="child.id"
type="button"
class="child-row"
@click.stop="emit('open', child.id)"
>
<span class="child-title">{{ child.title }}</span>
<span class="child-tail">
<span v-if="isStalled(child)" class="child-stalled" title="hängt"><AlertTriangle :size="10" /></span>
<span class="child-state" :class="childStateClass(child.state)">{{ childStateLabel(child.state) }}</span>
</span>
</button>
</div>
</div>
<!-- Review-Aktionen -->
<div v-if="column === 'review' && canReview" class="review-actions" @click.stop>
<button class="rv-approve" @click="emit('approve', task.id)"><Check :size="13" /> Abnehmen</button>
<button class="rv-changes" @click="emit('requestChanges', task)"><RotateCcw :size="13" /> Änderung</button>
</div>
<div class="mcard-meta">
<span>Update {{ relTime(task.lastActivityAt ?? task.updatedAt) }}</span>
</div>
</div>
</template>
<style scoped>
.mcard {
padding: 11px 12px;
border-radius: var(--r-sm, 10px);
background: linear-gradient(160deg, rgba(28,24,64,.45), rgba(20,17,48,.35));
border: 1px solid var(--line);
cursor: pointer;
transition: transform .15s, box-shadow .2s, border-color .15s;
text-align: left;
width: 100%;
}
.mcard:hover { transform: translateY(-1px); border-color: var(--line-2); box-shadow: 0 8px 24px -6px rgba(0,0,0,.4); }
.mcard-blocked { border-left: 3px solid var(--st-block); }
.mcard-stalled { border-left: 3px solid var(--st-queue); }
.mcard-top { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; flex-wrap: wrap; }
.ball { display: inline-flex; align-items: center; gap: 4px; font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 20px; }
.ball.is-iris { background: rgba(147, 51, 234, .16); color: var(--clr-iris); }
.ball.is-bao { background: rgba(59, 130, 246, .16); color: var(--clr-bao); }
.ball.is-agent { background: rgba(16, 185, 129, .14); color: var(--clr-agent); }
.ball-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.prio { font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 4px; border: 1px solid; background: transparent; }
.prio-high { color: var(--st-block); border-color: var(--st-block); }
.prio-med { color: var(--st-queue); border-color: var(--st-queue); }
.prio-low { color: var(--a-blue); border-color: var(--a-blue); }
.stalled-chip { display: inline-flex; align-items: center; gap: 3px; margin-left: auto; font-size: 9.5px; font-weight: 600; color: var(--st-queue); background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); padding: 1px 6px; border-radius: 20px; }
.mcard-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
.mcard-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mcard-progress { margin-top: 9px; }
.progress-row { display: flex; align-items: center; gap: 8px; }
.expand-btn { display: grid; place-items: center; width: 20px; height: 20px; border: none; border-radius: 6px; background: rgba(124,108,255,.08); color: var(--tx-2); cursor: pointer; transition: transform .15s, background .15s; flex: 0 0 auto; }
.expand-btn:hover { background: rgba(124,108,255,.16); color: var(--tx); }
.expand-btn.open { transform: rotate(90deg); }
.progress-text { font-size: 10.5px; color: var(--tx-2); font-family: 'Manrope', sans-serif; }
.avatars { margin-left: auto; display: flex; }
.avatar-mini { width: 20px; height: 20px; margin-left: -6px; border-radius: 50%; background: var(--grad-soft); border: 1px solid var(--space-1); display: grid; place-items: center; font-size: 8px; font-weight: 700; color: var(--tx); font-family: 'JetBrains Mono', monospace; }
.avatar-mini:first-child { margin-left: 0; }
.progress-track { height: 4px; margin-top: 6px; border-radius: 2px; background: var(--space-3); overflow: hidden; }
.progress-fill { height: 100%; border-radius: 2px; background: var(--grad); transition: width .3s; }
.children { margin-top: 10px; padding-top: 9px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 9px; cursor: default; }
.child-group-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.child-agent { font-size: 9.5px; font-weight: 700; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; letter-spacing: .03em; }
.child-agent.is-iris { background: rgba(147, 51, 234, .14); color: var(--clr-iris); }
.child-agent.is-bao { background: rgba(59, 130, 246, .14); color: var(--clr-bao); }
.child-agent.is-agent { background: rgba(16, 185, 129, .12); color: var(--clr-agent); }
.child-group-count { font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--tx-3); }
.child-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 5px 7px; border: none; border-radius: 7px; background: rgba(10,9,24,.4); color: var(--tx); cursor: pointer; text-align: left; transition: background .15s; }
.child-row:hover { background: rgba(124,108,255,.08); }
.child-title { flex: 1; font-size: 11px; line-height: 1.35; word-break: break-word; }
.child-tail { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; }
.child-stalled { color: var(--st-queue); display: inline-flex; }
.child-state { font-size: 8.5px; font-weight: 700; padding: 1px 6px; border-radius: 10px; text-transform: uppercase; letter-spacing: .03em; }
.cs-done { background: rgba(61,220,151,.14); color: var(--st-work); }
.cs-blocked { background: rgba(251,113,133,.14); color: var(--st-block); }
.cs-review { background: rgba(251, 146, 60, .14); color: var(--clr-review); }
.cs-active { background: rgba(52,214,245,.14); color: var(--st-think); }
.cs-backlog { background: var(--glass-2); color: var(--tx-3); }
.review-actions { display: flex; gap: 6px; margin-top: 10px; }
.rv-approve, .rv-changes { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 8px; border-radius: 8px; font-size: 10.5px; font-weight: 600; font-family: 'Manrope', sans-serif; cursor: pointer; transition: filter .15s, background .15s; }
.rv-approve { border: none; background: rgba(61,220,151,.16); color: var(--st-work); border: 1px solid rgba(61,220,151,.3); }
.rv-approve:hover { background: rgba(61,220,151,.26); }
.rv-changes { border: 1px solid rgba(251, 146, 60, .3); background: rgba(251, 146, 60, .12); color: var(--clr-review); }
.rv-changes:hover { background: rgba(251,146,60,.22); }
.mcard-meta { font-family: 'JetBrains Mono', monospace; font-size: 9.5px; color: var(--tx-3); margin-top: 7px; font-variant-numeric: tabular-nums; }
</style>
@@ -298,7 +298,7 @@ function avatarLabel() {
.m-av.iris { .m-av.iris {
background: var(--grad); background: var(--grad);
color: #fff; color: var(--tx);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
} }
@@ -328,13 +328,13 @@ function avatarLabel() {
font-weight: 600; font-weight: 600;
border: 1px solid transparent; border: 1px solid transparent;
} }
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); } .badge-blue { background:rgba(79,124,255,.14); color:var(--a-blue); border-color:rgba(79,124,255,.3); }
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); } .badge-purple { background:rgba(181,87,246,.14); color:var(--a-purple); border-color:rgba(181,87,246,.3); }
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); } .badge-amber { background:rgba(251,191,36,.13); color:var(--st-queue); border-color:rgba(251,191,36,.3); }
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); } .badge-green { background:rgba(61,220,151,.13); color:var(--st-work); border-color:rgba(61,220,151,.3); }
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); } .badge-cyan { background:rgba(52,214,245,.13); color:var(--st-think); border-color:rgba(52,214,245,.3); }
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); } .badge-rose { background:rgba(251,113,133,.13); color:var(--st-block); border-color:rgba(251,113,133,.3); }
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); } .badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
.m-pill { .m-pill {
display: inline-flex; display: inline-flex;
@@ -428,7 +428,7 @@ function avatarLabel() {
} }
.m-bar.work i { .m-bar.work i {
background: linear-gradient(90deg, #2bb87f, #3ddc97); background: linear-gradient(90deg, var(--st-work), var(--st-work));
box-shadow: var(--glow-work); box-shadow: var(--glow-work);
} }
@@ -510,7 +510,7 @@ function avatarLabel() {
font-family: 'JetBrains Mono', monospace; font-family: 'JetBrains Mono', monospace;
font-size: 12px; font-size: 12px;
line-height: 1.7; line-height: 1.7;
color: #9fe8fb; color: var(--st-think);
min-height: 72px; min-height: 72px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@@ -593,7 +593,7 @@ function avatarLabel() {
.m-model-btn.active { .m-model-btn.active {
background: var(--grad); background: var(--grad);
border: none; border: none;
color: #fff; color: var(--tx);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
} }
@@ -157,7 +157,7 @@ defineEmits<{
.nc-av.iris-av { .nc-av.iris-av {
background: var(--grad); background: var(--grad);
color: #fff; color: var(--tx);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
} }
@@ -255,7 +255,7 @@ defineEmits<{
} }
.node.is-work .nc-bar i { .node.is-work .nc-bar i {
background: linear-gradient(90deg, #2bb87f, #3ddc97); background: linear-gradient(90deg, var(--st-work), var(--st-work));
} }
/* ── Meta ────────────────────────────────────── */ /* ── Meta ────────────────────────────────────── */
@@ -159,7 +159,7 @@ defineEmits<{
border: 1px solid rgba(251,113,133,.3); border: 1px solid rgba(251,113,133,.3);
font-size: 12.5px; font-size: 12.5px;
font-weight: 600; font-weight: 600;
color: #fda4b0; color: var(--st-block);
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s;
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
@@ -86,7 +86,7 @@ function renderEdges() {
const edgeList = buildEdges(props.agents) const edgeList = buildEdges(props.agents)
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>` let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="var(--a-blue)"/><stop offset="1" stop-color="var(--a-purple)"/></linearGradient></defs>`
let paths = '' let paths = ''
let pulses = '' let pulses = ''
let idCounter = 0 let idCounter = 0
@@ -105,17 +105,17 @@ function renderEdges() {
if (e.kind === 'flow' && live) { if (e.kind === 'flow' && live) {
// Active flow: gradient stroke + animate pulse // Active flow: gradient stroke + animate pulse
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>` paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>` paths += `<path d="${d}" fill="none" stroke="var(--st-work)" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>` pulses += `<circle r="3.4" fill="var(--st-work)"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
} else if (e.kind === 'flow') { } else if (e.kind === 'flow') {
// Inactive flow // Inactive flow
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>` paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>` pulses += `<circle r="2.8" fill="var(--a-mid)" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
} else { } 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.52 : 0.34 const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>` paths += `<path d="${d}" fill="none" stroke="var(--a-mid)" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
} }
}) })
@@ -336,7 +336,7 @@ function handleReset() {
border-radius: 10px; border-radius: 10px;
background: var(--grad); background: var(--grad);
border: none; border: none;
color: #fff; color: var(--tx);
font-family: 'Manrope', sans-serif; font-family: 'Manrope', sans-serif;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -123,7 +123,7 @@ watch(
.iris-av :deep(svg) { .iris-av :deep(svg) {
width: 18px; width: 18px;
height: 18px; height: 18px;
color: #fff; color: var(--tx);
} }
.iris-name { .iris-name {
@@ -196,7 +196,7 @@ watch(
padding: 24px 0; padding: 24px 0;
} }
.chat-msg-info.error { color: #fda4b0; font-style: normal; } .chat-msg-info.error { color: var(--st-block); font-style: normal; }
.chat-row { .chat-row {
display: flex; display: flex;
@@ -222,7 +222,7 @@ watch(
.bubble.me { .bubble.me {
background: var(--grad); background: var(--grad);
color: #fff; color: var(--tx);
border-bottom-right-radius: 5px; border-bottom-right-radius: 5px;
margin-left: auto; margin-left: auto;
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
@@ -313,6 +313,6 @@ watch(
.send :deep(svg) { .send :deep(svg) {
width: 17px; width: 17px;
height: 17px; height: 17px;
color: #fff; color: var(--tx);
} }
</style> </style>
@@ -12,7 +12,7 @@ function prioLabel(p: TaskItem['priority']): string {
} }
function prioColor(p: TaskItem['priority']): string { function prioColor(p: TaskItem['priority']): string {
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff' return p === 'high' ? 'var(--st-block)' : p === 'medium' ? 'var(--st-queue)' : 'var(--a-blue)'
} }
function dotClass(s: TaskItem['status']): string { function dotClass(s: TaskItem['status']): string {
@@ -1,105 +0,0 @@
<script setup lang="ts">
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
defineProps<{
connected: boolean
}>()
defineEmits<{
toggleMobileNav: []
}>()
</script>
<template>
<header class="topbar">
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
<Command :size="19" />
</button>
<div class="search">
<Search :size="16" />
<span>Search operations</span>
<kbd> K</kbd>
</div>
<div class="top-actions">
<span :class="['connection', connected ? 'live' : 'preview']">
<CircleDot :size="13" />
{{ connected ? 'Live' : 'Preview data' }}
</span>
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
</div>
</header>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 22px;
border-bottom: 1px solid var(--line);
background: rgba(8, 6, 20, 0.5);
backdrop-filter: blur(14px);
}
.mobile-menu { display: none; }
.search {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
max-width: 560px;
padding: 8px 14px;
border: 1px solid var(--line);
border-radius: 11px;
background: rgba(124, 108, 255, 0.06);
color: var(--tx-3);
font-size: 11px;
}
.search kbd {
margin-left: auto;
padding: 1px 4px;
border: 1px solid var(--line-2);
border-radius: 4px;
font-size: 8px;
color: var(--tx-3);
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.connection {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
padding: 4px 11px;
border-radius: 20px;
border: 1px solid var(--line-2);
background: rgba(124, 108, 255, 0.07);
color: var(--tx-2);
}
.connection.live { color: var(--st-work); }
.connection.preview { color: var(--st-queue); }
.ask {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border: none;
border-radius: 10px;
background: var(--grad);
box-shadow: var(--glow-purple);
color: #fff;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: filter .16s;
}
.ask:hover { filter: brightness(1.08); }
@media (max-width: 860px) {
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--line-2); border-radius: 9px; background: transparent; color: var(--tx-2); cursor: pointer; }
}
</style>
@@ -1,233 +0,0 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import {
Activity, Bell, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar,
} from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useNotificationStore } from '../../stores/notifications'
import { initials } from '../../utils/format'
const props = defineProps<{
activeView: string
mobileNavOpen: boolean
queuedTasks: number
incidents: number
}>()
const emit = defineEmits<{
navigate: [label: string]
}>()
const auth = useAuthStore()
const router = useRouter()
const notificationStore = useNotificationStore()
onMounted(() => {
notificationStore.startPolling()
})
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen },
{ label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo },
{ label: 'Incidents', icon: AlertTriangle },
{ separator: true },
{ label: 'Notifications', icon: Bell },
{ label: 'Calendar', icon: Calendar },
{ label: 'Agents', icon: Bot },
{ label: 'Models', icon: SlidersHorizontal },
{ label: 'Activity', icon: Activity },
{ label: 'Mobile Chat', icon: MessageSquareText },
]
function onNavigate(label: string) {
emit('navigate', label)
}
async function logout() {
await auth.logout()
await router.replace('/login')
}
</script>
<template>
<aside :class="['sidebar', { open: mobileNavOpen }]">
<div class="brand">
<div class="brand-mark"><Command :size="18" /></div>
<div>
<strong>NEXUS</strong>
<span>Noveria Operations</span>
</div>
</div>
<nav class="nav">
<template v-for="item in navigation" :key="item.label ?? 'sep'">
<div v-if="item.separator" class="nav-separator"></div>
<button
v-else
:class="{ active: activeView === item.label }"
@click="onNavigate(item.label)"
>
<component :is="item.icon" :size="17" />
<span>{{ item.label }}</span>
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
</button>
</template>
</nav>
<div class="sidebar-bottom">
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
<button class="owner" type="button" title="Sign out" @click="logout">
<div class="avatar">{{ ownerInitials }}</div>
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
<LogOut :size="15" />
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 210px;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
flex-shrink: 0;
padding: 0 8px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 10px 12px;
}
.brand-mark {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--grad);
box-shadow: var(--glow-purple);
color: #fff;
}
.brand div strong { display: block; font-family: 'Space Grotesk', sans-serif; font-size: 10px; letter-spacing: .12em; }
.brand div span { font-size: 8px; color: var(--tx-3); }
.nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 0;
overflow-y: auto;
}
.nav button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--tx-2);
font-size: 10.5px;
text-align: left;
cursor: pointer;
transition: background .15s, color .15s;
}
.nav button:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.nav button.active {
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
color: #fff;
font-weight: 600;
}
.nav button i {
margin-left: auto;
background: rgba(124,108,255,.14);
border: 1px solid var(--line-2);
color: var(--tx);
font-family: 'JetBrains Mono', monospace;
font-style: normal;
font-size: 8px;
font-weight: 700;
padding: 1px 6px;
border-radius: 20px;
line-height: 1.4;
}
.nav button i.badge-red {
background: rgba(251,113,133,.18);
border-color: rgba(251,113,133,.4);
color: var(--st-block);
}
.nav-separator {
height: 1px;
margin: 6px 10px;
background: var(--line);
}
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line); }
.sidebar-bottom > button {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--tx-2);
font-size: 10.5px;
cursor: pointer;
transition: background .15s, color .15s;
}
.sidebar-bottom > button:hover { background: rgba(124,108,255,.08); color: var(--tx); }
.sidebar-bottom > button.active {
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
color: #fff;
font-weight: 600;
}
.owner {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.owner div strong { display: block; font-size: 9px; color: var(--tx); }
.owner div span { font-size: 7.5px; color: var(--tx-3); text-transform: capitalize; }
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
.owner:hover > svg:last-child { opacity: 1; }
.avatar {
width: 26px;
height: 26px;
border-radius: 8px;
display: grid;
place-items: center;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
font-size: 9px;
font-weight: 700;
}
@media (max-width: 860px) {
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
.sidebar.open { transform: translateX(0); }
}
</style>
@@ -1,36 +0,0 @@
<script setup lang="ts">
import type { NavItemDef } from '../../composables/icons'
import NavItem from './NavItem.vue'
defineProps<{
label: string
items: NavItemDef[]
}>()
</script>
<template>
<div class="nav-group">
<div class="nav-group-label">{{ label }}</div>
<NavItem
v-for="item in items"
:key="item.label"
:icon="item.icon"
:label="item.label"
:route="item.route"
:count="item.count"
:active="item.active"
/>
</div>
</template>
<style scoped>
.nav-group-label {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
padding: 16px 10px 7px;
font-family: 'Manrope', sans-serif;
}
</style>
-126
View File
@@ -1,126 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { icons } from '../../composables/icons'
const props = defineProps<{
icon: string
label: string
route?: string
count?: string
active?: boolean
}>()
const router = useRouter()
const route = useRoute()
const isActive = computed(() => {
if (props.active) return true
if (props.route && route.path === props.route) return true
return false
})
function navigate() {
if (props.route) {
router.push(props.route)
}
}
</script>
<template>
<button
:class="['nav-item', { active: isActive }]"
@click="navigate"
>
<!-- Icon -->
<span class="nav-icon" v-html="icons[icon] || ''"></span>
<!-- Label -->
<span class="nav-label">{{ label }}</span>
<!-- Count badge -->
<span v-if="count !== undefined" class="count">{{ count }}</span>
</button>
</template>
<style scoped>
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: background .16s, color .16s;
text-decoration: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: rgba(124,108,255,.08);
color: var(--tx);
}
.nav-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 3px;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
flex: 0 0 auto;
opacity: .85;
}
.nav-icon :deep(svg) {
width: 17px;
height: 17px;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(124,108,255,.16);
color: var(--tx);
line-height: 1.4;
flex-shrink: 0;
}
</style>
+267 -146
View File
@@ -1,139 +1,180 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* Sidebar — kompakte Icon-Rail (V2-Shell, alle Seiten)
*
* Collapsed 68px, expandiert bei Hover auf 232px als Overlay
* (kein Layout-Shift im Content). Ersetzt Sidebar + Topbar.
* Mobile: als Drawer über mobileOpen/close.
*/
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents' import { useNotificationStore } from '../../stores/notifications'
import { useTaskStore } from '../../stores/tasks' import { useLiveSyncStore } from '../../stores/liveSync'
import { navigation, icons } from '../../composables/icons' import { railNav, railFooterNav, svg } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons' import { initials } from '../../utils/format'
defineProps<{ defineProps<{
mobileOpen?: boolean mobileOpen?: boolean
}>() }>()
defineEmits<{ const emit = defineEmits<{
close: [] close: []
}>() }>()
import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format'
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute()
const router = useRouter() const router = useRouter()
const agentStore = useAgentStore() const notificationStore = useNotificationStore()
const taskStore = useTaskStore() const liveSync = useLiveSyncStore()
const ownerInitials = computed(() => const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW' auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
) )
function logout() { function isActive(itemRoute?: string): boolean {
auth.logout() if (!itemRoute) return false
router.replace('/login') if (route.path === itemRoute) return true
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
return route.path.startsWith(itemRoute + '/')
} }
/** function navigate(itemRoute?: string) {
* Dynamische Nav-Item-Counts aus den Stores. if (!itemRoute) return
* Überschreibt die hartcodierten `count`-Werte im navigation-Array. emit('close')
*/ router.push(itemRoute)
const dynamicNavigation = computed<NavGroupDef[]>(() => { }
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
return navigation.map(group => ({
...group,
items: group.items.map(item => {
let dynamicCount: string | undefined
switch (item.label) { async function logout() {
case 'Agenten': await auth.logout()
case 'Hosts · OpenClaw': await router.replace('/login')
dynamicCount = String(agentStore.agentList.length) }
break
case 'Task Board':
dynamicCount = String(taskStore.taskList.length)
break
case 'Kosten & Tokens':
dynamicCount = agentStore.todayCost
break
case 'Docs & .md':
dynamicCount = '0'
break
case 'Incidents':
dynamicCount = '0'
break
}
return { const statusLabel = computed(() => {
...item, if (liveSync.connected) return 'Live'
count: dynamicCount ?? item.count, if (liveSync.connecting) return 'Verbinde…'
} return 'Polling'
}),
}))
}) })
</script> </script>
<template> <template>
<aside :class="['sidebar', { open: mobileOpen }]"> <aside :class="['rail', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<!-- Brand --> <!-- Brand -->
<div class="side-top"> <button class="rail-brand" @click="navigate('/dashboard')">
<div class="brand-mark" v-html="icons.command || ''"></div> <span class="brand-mark" v-html="svg('command')"></span>
<div> <span class="rail-label brand-label">NEXUS</span>
<div class="brand-name">NEXUS</div> </button>
<div class="brand-sub">Mission Control</div>
</div>
</div>
<!-- Navigation --> <!-- Navigation -->
<nav class="nav"> <nav class="rail-nav v2-scroll">
<NavGroup <button
v-for="(group, idx) in dynamicNavigation" v-for="item in railNav"
:key="idx" :key="item.route"
:label="group.group" :class="['rail-item', { active: isActive(item.route) }]"
:items="group.items" :title="item.label"
/> @click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-dot"
></span>
<span class="rail-label">{{ item.label }}</span>
<span
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
class="rail-count"
>{{ notificationStore.unreadCount }}</span>
</button>
</nav> </nav>
<!-- Footer --> <!-- Footer -->
<div class="side-foot"> <div class="rail-foot">
<div class="avatar">{{ ownerInitials }}</div> <div class="rail-item static" :title="statusLabel">
<div class="owner-info"> <span class="rail-icon">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div> <span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div> </span>
<span class="rail-label dim">{{ statusLabel }}</span>
</div>
<button
v-for="item in railFooterNav"
:key="item.route"
:class="['rail-item', { active: isActive(item.route) }]"
:title="item.label"
@click="navigate(item.route)"
>
<span class="rail-icon" v-html="svg(item.icon)"></span>
<span class="rail-label">{{ item.label }}</span>
</button>
<div class="rail-owner">
<span class="avatar">{{ ownerInitials }}</span>
<span class="rail-label owner-label">
<span class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</span>
<span class="owner-role">{{ auth.user?.role ?? 'Owner' }}</span>
</span>
<button class="logout-btn rail-label" title="Abmelden" @click="logout" v-html="svg('logout')"></button>
</div> </div>
</div> </div>
</aside> </aside>
</template> </template>
<style scoped> <style scoped>
.sidebar { .rail {
width: 248px; position: absolute;
flex: 0 0 248px; inset: 0 auto 0 0;
height: 100vh; width: 68px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92)); background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
padding: 0; overflow: hidden;
position: relative; transition: width .18s ease, box-shadow .18s ease;
z-index: 2; z-index: 100;
} }
.side-top { .rail:hover {
width: 232px;
box-shadow: 24px 0 60px -30px rgba(0, 0, 0, .8);
}
/* Labels: unsichtbar bis die Rail expandiert */
.rail-label {
opacity: 0;
white-space: nowrap;
transition: opacity .14s ease .04s;
font-size: 13px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.rail:hover .rail-label,
.rail.open .rail-label {
opacity: 1;
}
/* ── Brand ── */
.rail-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 11px; gap: 13px;
padding: 18px 18px 16px; padding: 15px;
border: none;
background: transparent;
cursor: pointer;
} }
.brand-mark { .brand-mark {
width: 38px; width: 38px;
height: 38px; height: 38px;
flex: 0 0 38px;
border-radius: 11px; border-radius: 11px;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--grad); background: var(--grad);
box-shadow: var(--glow-purple); box-shadow: var(--glow-purple);
flex: 0 0 auto;
} }
.brand-mark :deep(svg) { .brand-mark :deep(svg) {
@@ -142,108 +183,154 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
color: #fff; color: #fff;
} }
.brand-name { .brand-label {
font-family: 'Space Grotesk', sans-serif; font-family: 'Space Grotesk', sans-serif;
font-weight: 700; font-weight: 700;
font-size: 17px; font-size: 16px;
letter-spacing: .14em; letter-spacing: .14em;
line-height: 1; color: var(--tx);
} }
.brand-sub { /* ── Nav ── */
font-size: 10.5px; .rail-nav {
color: var(--tx-3);
letter-spacing: .05em;
margin-top: 3px;
}
.nav {
flex: 1; flex: 1;
overflow-y: auto;
padding: 6px 12px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 3px;
padding: 6px 12px;
overflow-y: auto;
overflow-x: hidden;
} }
.side-foot { .rail-item {
padding: 12px; position: relative;
border-top: 1px solid var(--line);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 13px;
height: 42px;
padding: 0 13px;
flex: 0 0 auto;
border: none;
border-radius: 11px;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-weight: 500;
text-align: left;
cursor: pointer; cursor: pointer;
transition: background .15s; transition: background .15s, color .15s;
} }
.side-foot:hover { .rail-item:not(.static):hover {
background: rgba(124,108,255,.06); background: rgba(124, 108, 255, .08);
color: var(--tx);
} }
.sidebar-close { .rail-item.active {
display: none; color: #fff;
background: linear-gradient(90deg, rgba(124, 108, 255, .22), rgba(124, 108, 255, .04));
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, .25);
} }
@media (max-width: 767px) { .rail-item.static {
.sidebar { cursor: default;
position: fixed; }
left: 0;
top: 0;
z-index: 100;
height: 100vh;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open { .rail-icon {
transform: translateX(0); width: 18px;
} height: 18px;
flex: 0 0 18px;
display: grid;
place-items: center;
opacity: .9;
}
.sidebar-close { .rail-icon :deep(svg) {
display: flex; width: 18px;
align-items: center; height: 18px;
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 { .rail-item .rail-label {
background: rgba(124,108,255,.1); flex: 1;
color: var(--tx); }
}
.sidebar-close :deep(svg) { /* Ungelesen-Punkt am Icon (collapsed sichtbar) */
width: 18px; .rail-dot {
height: 18px; position: absolute;
} left: 24px;
top: 9px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--st-block);
box-shadow: 0 0 8px rgba(251, 113, 133, .8);
}
.rail-count {
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(251, 113, 133, .16);
border: 1px solid rgba(251, 113, 133, .35);
color: var(--st-block);
}
/* ── Footer ── */
.rail-foot {
display: flex;
flex-direction: column;
gap: 3px;
padding: 6px 12px 10px;
border-top: 1px solid var(--line);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-dot.on {
background: var(--st-work);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.rail-label.dim {
color: var(--tx-3);
font-size: 12px;
}
.rail-owner {
display: flex;
align-items: center;
gap: 13px;
padding: 7px 5px 2px;
} }
.avatar { .avatar {
width: 34px; width: 34px;
height: 34px; height: 34px;
flex: 0 0 34px;
border-radius: 10px; border-radius: 10px;
background: var(--grad-soft); background: var(--grad-soft);
border: 1px solid var(--line-2); border: 1px solid var(--line-2);
display: grid; display: grid;
place-items: center; place-items: center;
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 12px;
color: var(--tx); color: var(--tx);
flex-shrink: 0;
} }
.owner-info { .owner-label {
min-width: 0; flex: 1;
display: flex;
flex-direction: column;
} }
.owner-name { .owner-name {
@@ -258,7 +345,41 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
.owner-role { .owner-role {
font-size: 10px; font-size: 10px;
color: var(--tx-3); color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize; text-transform: capitalize;
} }
.logout-btn {
border: none;
background: transparent;
color: var(--tx-3);
cursor: pointer;
padding: 6px;
border-radius: 8px;
display: grid;
place-items: center;
}
.logout-btn:hover {
color: var(--st-block);
background: rgba(251, 113, 133, .1);
}
.logout-btn :deep(svg) {
width: 16px;
height: 16px;
}
/* ── Mobile: Drawer ── */
@media (max-width: 767px) {
.rail {
position: fixed;
width: 232px;
transform: translateX(-100%);
transition: transform .22s ease;
}
.rail.open {
transform: translateX(0);
}
}
</style> </style>
-210
View File
@@ -1,210 +0,0 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
defineProps<{
connected?: boolean
statusLabel?: string
}>()
defineEmits<{
'toggle-sidebar': []
}>()
</script>
<template>
<header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<!-- Search -->
<div class="search">
<span class="search-icon" v-html="icons.search || ''"></span>
<span class="search-placeholder">Operationen, Agents oder Tasks suchen</span>
</div>
<!-- Spacer -->
<div class="spacer"></div>
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span>
<!-- Ask Iris Button -->
<button class="btn btn-primary ask-iris-btn">
<span class="btn-icon" v-html="icons.spark || ''"></span>
<span class="ask-label">Ask Iris</span>
</button>
</header>
</template>
<style scoped>
.topbar {
height: 62px;
flex: 0 0 62px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: rgba(8,6,20,.5);
backdrop-filter: blur(14px);
}
.search {
flex: 1;
max-width: 560px;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 14px;
border-radius: 11px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
color: var(--tx-3);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
}
.search-icon :deep(svg) {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.search-placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
font-family: 'Manrope', sans-serif;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-dot.on {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-family: 'Manrope', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
border: none;
transition: filter .16s;
}
.btn-primary {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
.hamburger {
display: none;
}
@media (max-width: 767px) {
.topbar {
padding: 0 14px;
}
.search {
flex: 1;
max-width: none;
}
.hamburger {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
flex: 0 0 auto;
}
.hamburger:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.hamburger :deep(svg) {
width: 20px;
height: 20px;
}
.pill {
display: none;
}
.ask-iris-btn {
width: 32px;
height: 32px;
padding: 0;
display: grid;
place-items: center;
border-radius: 9px;
flex: 0 0 auto;
}
.ask-label {
display: none;
}
.ask-iris-btn .btn-icon {
display: flex;
}
}
</style>
@@ -0,0 +1,103 @@
<script setup lang="ts">
/**
* ActivityTimeline — Wiederverwendbare Aktivitäts-Feed-Komponente
*
* Standardisierte Timeline-Darstellung für Task-Aktivität,
* Agent-Aktivität und allgemeine Ereignis-Feeds.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
export interface ActivityEntry {
id?: string
message: string
timestamp?: string
type?: string
}
withDefaults(defineProps<{
entries: ActivityEntry[]
loading?: boolean
emptyLabel?: string
}>(), {
emptyLabel: 'Noch keine Aktivität',
})
function formatDate(date?: string | null): string {
if (!date) return ''
return new Date(date).toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
</script>
<template>
<div class="activity-timeline">
<!-- Loading -->
<div v-if="loading" class="activity-empty">Lade</div>
<!-- Empty -->
<div v-else-if="!entries.length" class="activity-empty">{{ emptyLabel }}</div>
<!-- Entries -->
<article v-for="(entry, index) in entries" :key="entry.id ?? index" class="activity-item">
<div class="activity-dot"></div>
<div class="activity-body">
<div class="activity-message">{{ entry.message }}</div>
<div v-if="entry.timestamp" class="activity-time">
{{ formatDate(entry.timestamp) }}
</div>
</div>
</article>
</div>
</template>
<style scoped>
.activity-timeline {
display: flex;
flex-direction: column;
gap: 10px;
}
.activity-empty {
font-size: 11px;
color: var(--tx-3);
font-style: italic;
padding: 8px;
text-align: center;
font-family: 'Manrope', sans-serif;
}
.activity-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.activity-dot {
width: 8px;
height: 8px;
border-radius: 999px;
margin-top: 5px;
background: var(--grad);
box-shadow: 0 0 0 4px rgba(124, 108, 255, .12);
flex-shrink: 0;
}
.activity-body {
min-width: 0;
}
.activity-message {
color: var(--tx);
font-size: 12.5px;
line-height: 1.45;
}
.activity-time {
color: var(--tx-3);
font-size: 10.5px;
margin-top: 4px;
}
</style>
+97
View File
@@ -1,4 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* Badge — Nexus V2 Status- und Label-Badge
*
* Erweitert: zusätzliche nexus-token-basierte Varianten für
* Agenten-Rollen, Status-Pills und Prioritäten.
* Original shadcn-Varianten bleiben erhalten.
*/
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority' import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -8,10 +15,25 @@ const badgeVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
// Original shadcn
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground', outline: 'text-foreground',
// Nexus V2 — Token-basierte Varianten (Farben aus nexus-tokens.css)
work: 'nexus-badge-work',
think: 'nexus-badge-think',
blocked: 'nexus-badge-blocked',
queue: 'nexus-badge-queue',
done: 'nexus-badge-done',
review: 'nexus-badge-review',
iris: 'nexus-badge-iris',
bao: 'nexus-badge-bao',
agent: 'nexus-badge-agent',
priorityHigh: 'nexus-badge-prio-high',
priorityMed: 'nexus-badge-prio-med',
priorityLow: 'nexus-badge-prio-low',
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -35,3 +57,78 @@ const props = withDefaults(defineProps<Props>(), {
<slot /> <slot />
</span> </span>
</template> </template>
<style scoped>
/* Nexus V2 Token-basierte Badge-Varianten */
.nexus-badge-work {
border-color: rgba(61, 220, 151, .25);
background: rgba(34, 197, 94, .12);
color: var(--st-work);
}
.nexus-badge-think {
border-color: rgba(52, 214, 245, .25);
background: rgba(52, 214, 245, .1);
color: var(--st-think);
}
.nexus-badge-blocked {
border-color: rgba(251, 113, 133, .25);
background: rgba(244, 63, 94, .12);
color: var(--st-block);
}
.nexus-badge-queue {
border-color: rgba(251, 191, 36, .25);
background: rgba(251, 191, 36, .12);
color: var(--st-queue);
}
.nexus-badge-done {
border-color: rgba(34, 197, 94, .25);
background: rgba(34, 197, 94, .12);
color: var(--st-work);
}
.nexus-badge-review {
border-color: rgba(249, 115, 22, .25);
background: rgba(249, 115, 22, .12);
color: var(--clr-review);
}
.nexus-badge-iris {
border-color: rgba(147, 51, 234, .25);
background: rgba(147, 51, 234, .12);
color: var(--clr-iris);
}
.nexus-badge-bao {
border-color: rgba(59, 130, 246, .25);
background: rgba(59, 130, 246, .12);
color: var(--clr-bao);
}
.nexus-badge-agent {
border-color: rgba(16, 185, 129, .2);
background: rgba(16, 185, 129, .12);
color: var(--clr-agent);
}
.nexus-badge-prio-high {
border-color: var(--st-block);
background: transparent;
color: var(--st-block);
}
.nexus-badge-prio-med {
border-color: var(--st-queue);
background: transparent;
color: var(--st-queue);
}
.nexus-badge-prio-low {
border-color: var(--a-blue);
background: transparent;
color: var(--a-blue);
}
</style>
+40 -2
View File
@@ -1,16 +1,54 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* Card — Nexus V2 glass-panel Karte
*
* Verwendet nexus-tokens.css als Single Source of Truth.
* Hintergrund-kompatibel mit shadcn-Card-Props.
* Wrapped content in .glass-panel styles aus tokens.css.
*/
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
interface Props { interface Props {
class?: HTMLAttributes['class'] class?: HTMLAttributes['class']
/** Variante: 'glass' (default) | 'raised' | 'subtle' */
variant?: 'glass' | 'raised' | 'subtle'
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
variant: 'glass',
})
</script> </script>
<template> <template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)"> <div
:class="cn(
'rounded-xl border',
{
'glass-panel': variant === 'glass',
'card-raised': variant === 'raised',
'card-subtle': variant === 'subtle',
},
props.class,
)"
>
<slot /> <slot />
</div> </div>
</template> </template>
<style scoped>
/* glass-panel ist in nexus-tokens.css definiert */
.card-raised {
background: var(--glass-2);
border: 1px solid var(--line-2);
border-radius: var(--r);
backdrop-filter: blur(12px);
}
.card-subtle {
background: rgba(255, 255, 255, .02);
border: 1px solid var(--line);
border-radius: var(--r);
}
</style>
+106
View File
@@ -0,0 +1,106 @@
<script setup lang="ts">
/**
* EmptyState — Standardisierte Leerzustands-Anzeige
*
* Konsistente Darstellung für "keine Daten"-Zustände im Dashboard,
* TaskBoard und allen V2-Views.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
withDefaults(defineProps<{
/** Icon-Klasse (optional, z.B. für SVG-Nutzung) */
icon?: string
/** Primärer Text */
title?: string
/** Sekundärer Erklärungstext */
description?: string
/** Kompakte Darstellung (inline-flex) */
compact?: boolean
}>(), {
title: 'Keine Einträge',
compact: false,
})
</script>
<template>
<div class="empty-state" :class="{ compact }">
<div v-if="icon" class="empty-icon" v-html="icon"></div>
<slot name="icon">
<div v-if="!icon" class="empty-icon-default">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<rect x="4" y="6" width="20" height="16" rx="3" stroke="currentColor" stroke-width="1.2" />
<line x1="10" y1="12" x2="18" y2="12" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<line x1="10" y1="16" x2="15" y2="16" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</div>
</slot>
<div v-if="!compact" class="empty-title">{{ title }}</div>
<div v-if="description" class="empty-desc">{{ description }}</div>
<div v-if="$slots.action" class="empty-action">
<slot name="action" />
</div>
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px 12px;
text-align: center;
}
.empty-state.compact {
flex-direction: row;
gap: 8px;
padding: 8px 12px;
justify-content: flex-start;
}
.empty-icon,
.empty-icon-default {
color: var(--tx-3);
opacity: 0.5;
margin-bottom: 8px;
}
.compact .empty-icon,
.compact .empty-icon-default {
margin-bottom: 0;
flex-shrink: 0;
}
.empty-icon-default {
display: grid;
place-items: center;
}
.empty-title {
font-size: 12px;
font-weight: 600;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
}
.compact .empty-title {
font-size: 11px;
font-weight: 400;
font-style: italic;
color: var(--tx-3);
}
.empty-desc {
margin-top: 4px;
font-size: 11px;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
max-width: 280px;
line-height: 1.4;
}
.empty-action {
margin-top: 12px;
}
</style>
@@ -0,0 +1,79 @@
<script setup lang="ts">
/**
* PageHeading — Standardisierter Seiten-Header für V2 Views
*
* Bietet konsistentes grad-text Styling, Eyebrow/Subtitle und Action-Slot.
* Verwendet ausschließlich nexus-tokens.css Variablen.
*/
withDefaults(defineProps<{
/** Gradient-Text Überschrift */
title: string
/** Kleiner Eyebrow-Text über der Überschrift (violett) */
eyebrow?: string
/** Subtitle unter der Überschrift */
subtitle?: string
}>(), {})
</script>
<template>
<header class="page-heading">
<div>
<p v-if="eyebrow || $slots.eyebrow" class="eyebrow">
<slot name="eyebrow">{{ eyebrow }}</slot>
</p>
<h1><span class="grad-text">{{ title }}</span></h1>
<p v-if="subtitle" class="board-subtitle">{{ subtitle }}</p>
<slot name="meta" />
</div>
<div v-if="$slots.actions" class="heading-actions">
<slot name="actions" />
</div>
</header>
</template>
<style scoped>
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.page-heading h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
font-family: 'Space Grotesk', sans-serif;
letter-spacing: -0.02em;
}
.eyebrow {
color: var(--a-mid);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
margin: 0 0 4px 0;
}
.board-subtitle {
margin: 4px 0 0;
font-size: 11px;
color: var(--tx-3);
font-family: 'Manrope', sans-serif;
}
.grad-text {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.heading-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
</style>
@@ -0,0 +1,116 @@
<script setup lang="ts">
/**
* SectionHeader — Titelzeile für Sektionen/Spalten im Dashboard V2
*
* Kapselt wiederholtes Muster: dot + label + count aus TaskBoard-Spalten
* und Iris-Panel-Sektionen. Verwendet ausschließlich Token-Farben.
*/
withDefaults(defineProps<{
label: string
/** Status-Farbe des Dots: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao' */
dotColor?: 'work' | 'think' | 'idle' | 'block' | 'queue' | 'review' | 'iris' | 'bao'
/** Numerischer Count (rechts) */
count?: number
/** Variante: 'column' (Spalten-Header) oder 'section' (Iris-Panel) */
variant?: 'column' | 'section'
}>(), {
variant: 'column',
})
function dotColorVar(color: string): string {
const map: Record<string, string> = {
work: 'var(--st-work)',
think: 'var(--st-think)',
idle: 'var(--st-idle)',
block: 'var(--st-block)',
queue: 'var(--st-queue)',
review: 'var(--st-queue)', // orange-ähnlich
iris: 'var(--a-purple)',
bao: 'var(--a-blue)',
}
return map[color] || 'var(--st-idle)'
}
function dotRingVar(color: string): string {
const map: Record<string, string> = {
work: 'rgba(61, 220, 151, .25)',
think: 'rgba(52, 214, 245, .25)',
idle: 'rgba(107, 103, 150, .25)',
block: 'rgba(251, 113, 133, .25)',
queue: 'rgba(251, 191, 36, .25)',
review: 'rgba(251, 146, 60, .25)',
iris: 'rgba(181, 87, 246, .25)',
bao: 'rgba(79, 124, 255, .25)',
}
return map[color] || 'rgba(107, 103, 150, .25)'
}
</script>
<template>
<div class="section-header" :class="variant">
<span
class="section-dot"
:style="{
background: dotColorVar(dotColor || 'idle'),
boxShadow: `0 0 0 2px ${dotRingVar(dotColor || 'idle')}`,
}"
></span>
<span class="section-label">{{ label }}</span>
<span v-if="count !== undefined" class="section-count">{{ count }}</span>
<slot />
</div>
</template>
<style scoped>
.section-header {
display: flex;
align-items: center;
gap: 8px;
}
.section-header.column {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.section-header.section {
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.section-dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
}
.section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--tx-2);
font-family: 'Space Grotesk', sans-serif;
}
.section-header.section .section-label {
font-size: 11px;
}
.section-count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 2px 8px;
border-radius: 10px;
background: var(--glass-2);
color: var(--tx-2);
border: 1px solid var(--line);
}
</style>
@@ -0,0 +1,101 @@
<script setup lang="ts">
/**
* SkeletonLoader — Platzhalter-Animation für Ladezustände
*
* Extrahiert aus dem Muster in TaskStrip (skeleton-pulse) und
* board-loading spinner. Zentrale Komponente mit zwei Varianten:
*
* - 'card' — Rechteckiger Karten-Skeleton
* - 'text' — Zeilen-Skeleton für Text
* - 'circle' — Runder Skeleton (Avatar, Dot)
*
* Alle Farben aus nexus-tokens.css Token.
*/
withDefaults(defineProps<{
/** Darstellungsform */
variant?: 'card' | 'text' | 'circle'
/** Höhe in px (nur für card/text) */
height?: number
/** Breite in px (optional, sonst 100%) */
width?: number
}>(), {
variant: 'card',
height: 78,
})
</script>
<template>
<div
class="skeleton"
:class="variant"
:style="{
height: variant === 'circle' ? `${width || height || 32}px` : `${height}px`,
width: width ? `${width}px` : '100%',
}"
>
<div v-if="variant === 'text'" class="skeleton-line" style="width: 60%"></div>
<div v-if="variant === 'text'" class="skeleton-line" style="width: 85%; margin-top: 8px"></div>
</div>
</template>
<style scoped>
.skeleton {
border-radius: var(--r-sm, 10px);
background: var(--glass);
overflow: hidden;
position: relative;
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(124, 108, 255, .06) 40%,
rgba(124, 108, 255, .12) 50%,
rgba(124, 108, 255, .06) 60%,
transparent 100%
);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
.skeleton.text {
background: transparent;
display: flex;
flex-direction: column;
padding: 4px 0;
}
.skeleton-line {
height: 10px;
border-radius: 5px;
background: var(--glass-2);
position: relative;
overflow: hidden;
}
.skeleton-line::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
rgba(124, 108, 255, .08) 50%,
transparent 100%
);
animation: skeleton-shimmer 1.8s ease-in-out infinite;
}
.skeleton.circle {
border-radius: 50%;
}
@keyframes skeleton-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
</style>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
/**
* StatusDot — Nexus V2 animierter Status-Indikator
*
* Kapselt die pulse-keyframes aus nexus-tokens.css.
* Status-Farben und Pulse-Animationen stammen ausschließlich aus Tokens.
*
* Props:
* status 'work' | 'think' | 'idle' | 'block' | 'queue'
* size 'sm' (8px) | 'md' (9px) | 'lg' (12px), default 'md'
* pulse Animation aktiv (default: true für work/think/block)
* label Text-Label rechts neben dem Dot (optional)
*/
withDefaults(defineProps<{
status: 'work' | 'think' | 'idle' | 'block' | 'queue'
size?: 'sm' | 'md' | 'lg'
pulse?: boolean
label?: string
}>(), {
size: 'md',
})
</script>
<template>
<span class="status-dot-wrapper">
<span
class="dot"
:class="[status, size, { pulse: pulse !== false }]"
:aria-label="label || status"
role="status"
></span>
<span v-if="label" class="dot-label">{{ label }}</span>
</span>
</template>
<style scoped>
.status-dot-wrapper {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
display: inline-block;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.sm { width: 7px; height: 7px; }
.dot.md { width: 9px; height: 9px; }
.dot.lg { width: 12px; height: 12px; }
/* Farben aus nexus-tokens.css — Single Source of Truth */
.dot.work { background: var(--st-work); }
.dot.think { background: var(--st-think); }
.dot.idle { background: var(--st-idle); }
.dot.block { background: var(--st-block); }
.dot.queue { background: var(--st-queue); }
/* Pulse-Animationen (Keyframes in tokens.css definiert) */
.dot.work.pulse { animation: pulse-work 1.8s infinite; box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
.dot.think.pulse { animation: pulse-think 1.8s infinite; box-shadow: 0 0 0 0 rgba(52,214,245,.55); }
.dot.block.pulse { animation: pulse-block 1.4s infinite; box-shadow: 0 0 0 0 rgba(251,113,133,.55); }
.dot-label {
font-size: 12.5px;
font-weight: 600;
color: var(--tx-2);
white-space: nowrap;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
/**
* StatusPill — Einheitliche State-Pill für Statusanzeigen
*
* Extrahiert aus dem doppelten Muster in TaskBoardView (detail-state-pill)
* und BoardCard (child-state). Verwendet ausschließlich Token aus
* nexus-tokens.css als Farbquelle.
*
* Variants:
* backlog | progress | review | blocked | done
*
* Optional: size 'sm' (kompakt) oder 'md' (default).
*/
withDefaults(defineProps<{
/** Status-Variante */
variant: 'backlog' | 'progress' | 'review' | 'blocked' | 'done'
/** Grösse: 'sm' oder 'md' */
size?: 'sm' | 'md'
}>(), {
size: 'md',
})
</script>
<template>
<span class="status-pill" :class="[variant, size]">
<slot />
</span>
</template>
<style scoped>
.status-pill {
display: inline-flex;
align-items: center;
gap: 4px;
border-radius: 999px;
font-weight: 700;
letter-spacing: .03em;
white-space: nowrap;
border: 1px solid transparent;
font-family: 'Manrope', sans-serif;
}
/* Sizes */
.status-pill.md { padding: 3px 9px; font-size: 10.5px; }
.status-pill.sm { padding: 1px 6px; font-size: 8.5px; text-transform: uppercase; }
/* Variants — alle Farben aus nexus-tokens.css */
.status-pill.backlog { color: var(--pill-backlog); background: rgba(251, 191, 36, .12); border-color: rgba(251, 191, 36, .25); }
.status-pill.progress { color: var(--pill-progress); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
.status-pill.review { color: var(--pill-review); background: rgba(249, 115, 22, .12); border-color: rgba(249, 115, 22, .25); }
.status-pill.blocked { color: var(--pill-blocked); background: rgba(244, 63, 94, .12); border-color: rgba(244, 63, 94, .25); }
.status-pill.done { color: var(--pill-done); background: rgba(34, 197, 94, .12); border-color: rgba(34, 197, 94, .25); }
</style>
@@ -7,18 +7,18 @@ const { toasts, remove } = useToast()
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = { const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
success: { success: {
icon: CheckCircle, icon: CheckCircle,
color: '#22c55e', color: 'var(--st-work)',
bg: 'rgba(34, 197, 94, 0.10)', bg: 'rgba(61, 220, 151, 0.10)',
}, },
error: { error: {
icon: XCircle, icon: XCircle,
color: '#ef4444', color: 'var(--st-block)',
bg: 'rgba(239, 68, 68, 0.10)', bg: 'rgba(251, 113, 133, 0.10)',
}, },
info: { info: {
icon: Info, icon: Info,
color: '#3b82f6', color: 'var(--a-blue)',
bg: 'rgba(59, 130, 246, 0.10)', bg: 'rgba(79, 124, 255, 0.10)',
}, },
} }
</script> </script>
@@ -76,7 +76,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
0 8px 32px rgba(0, 0, 0, 0.4), 0 8px 32px rgba(0, 0, 0, 0.4),
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent); inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
pointer-events: auto; pointer-events: auto;
color: #e8eaf0; color: var(--tx);
font-size: 12.5px; font-size: 12.5px;
line-height: 1.4; line-height: 1.4;
} }
@@ -106,7 +106,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
border: none; border: none;
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: #6b7385; color: var(--tx-3);
cursor: pointer; cursor: pointer;
opacity: 0.5; opacity: 0.5;
transition: all 0.15s; transition: all 0.15s;
@@ -114,7 +114,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
.toast-close:hover { .toast-close:hover {
opacity: 1; opacity: 1;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: #e8eaf0; color: var(--tx);
} }
/* Transition animations */ /* Transition animations */
+17 -7
View File
@@ -4,25 +4,35 @@ import { type VariantProps, cva } from 'class-variance-authority'
export { default as Button } from './Button.vue' export { default as Button } from './Button.vue'
export const buttonVariants = cva( export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
// shadcn-original
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
outline: secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
// Nexus V2 — Token-basierte Varianten
/** CTA / primäre Aktion — Gradient */
gradient: 'nexus-btn-gradient',
/** Icon-Only — quadratischer Button ohne Text */
icon: 'nexus-btn-icon',
/** Ghost thin — minimal hover */
ghostSubtle: 'nexus-btn-ghost-subtle',
/** Danger/Blocker — rote Akzent-Aktion */
danger: 'nexus-btn-danger',
}, },
size: { size: {
default: 'h-9 px-4 py-2', default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs', sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8', lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9', icon: 'h-9 w-9',
iconSm: 'h-7 w-7 rounded-md',
pill: 'h-7 px-4 rounded-full text-xs',
}, },
}, },
defaultVariants: { defaultVariants: {
+15
View File
@@ -0,0 +1,15 @@
export { default as Badge } from './Badge.vue'
export { default as Card } from './Card.vue'
export { default as StatusDot } from './StatusDot.vue'
export { default as PageHeading } from './PageHeading.vue'
export { default as EmptyState } from './EmptyState.vue'
export { default as ActivityTimeline } from './ActivityTimeline.vue'
export { default as SectionHeader } from './SectionHeader.vue'
export { default as StatusPill } from './StatusPill.vue'
export { default as SkeletonLoader } from './SkeletonLoader.vue'
export { default as Input } from './Input.vue'
export { default as Textarea } from './Textarea.vue'
export { default as Select } from './Select.vue'
export { default as Dialog } from './Dialog.vue'
export { default as ToastContainer } from './ToastContainer.vue'
export { Button } from './button'
+20 -41
View File
@@ -26,6 +26,9 @@ export const icons: Record<string, string> = {
plus: `<path d="M12 5v14M5 12h14"/>`, plus: `<path d="M12 5v14M5 12h14"/>`,
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`, command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`, gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
bell: `<path d="M18 9a6 6 0 1 0-12 0c0 6-2.5 7-2.5 7h17S18 15 18 9M10.3 20a2 2 0 0 0 3.4 0"/>`,
calendar: `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>`,
logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>`,
chevron_left: `<path d="m15 18-6-6 6-6"/>`, chevron_left: `<path d="m15 18-6-6 6-6"/>`,
chevron_right: `<path d="m9 18 6-6-6-6"/>`, chevron_right: `<path d="m9 18 6-6-6-6"/>`,
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`, dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
@@ -51,46 +54,22 @@ export interface NavGroupDef {
} }
/** /**
* Navigation structure matching NEXUS.nav from agents.js * Rail-Navigation — EINE Quelle für alle Seiten (Dashboard + Rest).
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
* unten im Fußbereich (railFooterNav).
*/ */
export const navigation: NavGroupDef[] = [ export const railNav: NavItemDef[] = [
{ { icon: 'grid', label: 'Dashboard', route: '/dashboard' },
group: 'Operations', { icon: 'cpu', label: 'Agenten', route: '/agents' },
items: [ { icon: 'list', label: 'Task Board', route: '/tasks' },
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true }, { icon: 'brain', label: 'Memory', route: '/memory' },
{ icon: 'cpu', label: 'Agenten', route: '/agents' }, { icon: 'doc', label: 'Docs', route: '/docs' },
{ icon: 'list', label: 'Task Board', route: '/tasks' }, { icon: 'calendar', label: 'Kalender', route: '/calendar' },
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' }, { icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
], { icon: 'alert', label: 'Incidents', route: '/incidents' },
}, { icon: 'shield', label: 'Security', route: '/security' },
{ ]
group: 'Knowledge',
items: [ export const railFooterNav: NavItemDef[] = [
{ icon: 'brain', label: 'Memory', route: '/memory' }, { icon: 'gear', label: 'Einstellungen', route: '/settings' },
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
{ icon: 'search', label: 'Research', route: '/research' },
],
},
{
group: 'Infrastructure',
items: [
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
{ icon: 'model', label: 'Modelle', route: '/models' },
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
],
},
{
group: 'Governance',
items: [
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
{ icon: 'shield', label: 'Security', route: '/security' },
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
],
},
{
group: 'System',
items: [
{ icon: 'gear', label: 'Settings', route: '/settings' },
],
},
] ]
+75
View File
@@ -0,0 +1,75 @@
/**
* useConfirm — Wiederverwendbare Confirmation-Dialog-Logik
*
* Extrahiert aus dem Modal-Muster in TaskBoardView
* (showCreateModal / showChangesModal / showDetailPanel),
* das sich auch in anderen Views wiederholt.
*
* Bietet reaktiven open/close-State, Form-Fehler-Management
* und Escape-Key-Bindung.
*/
import { ref, watch, onMounted, onUnmounted } from 'vue'
export function useConfirm() {
const isOpen = ref(false)
const error = ref('')
const success = ref('')
const submitting = ref(false)
function open() {
isOpen.value = true
error.value = ''
success.value = ''
}
function close() {
isOpen.value = false
}
function setError(msg: string) {
error.value = msg
success.value = ''
}
function setSuccess(msg: string) {
success.value = msg
error.value = ''
}
/** Escape-Taste schliesst den Dialog */
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value) {
close()
}
}
/** Overlay-Klick ausserhalb schliesst */
function onOverlayClick(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-overlay')) {
close()
}
}
/** Sperrt Body-Scroll, wenn Dialog offen */
watch(isOpen, (open) => {
document.body.style.overflow = open ? 'hidden' : ''
})
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
document.body.style.overflow = ''
})
return {
isOpen,
error,
success,
submitting,
open,
close,
setError,
setSuccess,
onOverlayClick,
}
}
+60
View File
@@ -0,0 +1,60 @@
/**
* useFormatDate — Einheitliche Datums-/Zeitformatierung für Nexus V2
*
* Kapselt die mehrfach wiederholten formatDate-, relativeTime- und
* toDateInputValue-Helper, die bisher in TaskBoardView, BoardCard,
* mit anderen Views dupliziert waren.
*
* Alle Ausgaben orientieren sich an de-DE Locale.
*/
/**
* Datum als lesbaren String (de-DE, kurzes/medium-DateStyle).
*/
export function formatDate(date?: string | null, withTime = false): string {
if (!date) return '—'
return new Date(date).toLocaleString('de-DE', withTime
? { dateStyle: 'medium', timeStyle: 'short' }
: { dateStyle: 'medium' })
}
/**
* ISO-Datumsstring in YYYY-MM-DD für <input type="date">.
*/
export function toDateInputValue(date?: string | null): string {
if (!date) return ''
return new Date(date).toISOString().slice(0, 10)
}
/**
* Relative Zeit ("gerade eben", "vor 5 min", "vor 3 h", "vor 2 d").
* Optionaler Fallback, wenn date fehlt.
*/
export function relativeTime(date?: string | null, fallback = 'keine Aktivität'): string {
if (!date) return fallback
const diffMs = Date.now() - new Date(date).getTime()
const mins = Math.max(0, Math.round(diffMs / 60000))
if (mins < 1) return 'gerade eben'
if (mins < 60) return `vor ${mins} min`
const hours = Math.round(mins / 60)
if (hours < 24) return `vor ${hours} h`
const days = Math.round(hours / 24)
return `vor ${days} d`
}
/**
* Minuten seit einem Datum (oder Infinity falls kein Datum).
*/
export function minutesSince(dateStr?: string | null): number {
if (!dateStr) return Infinity
return (Date.now() - new Date(dateStr).getTime()) / 60000
}
/**
* Stunden seit einem Datum (gerundet).
*/
export function hoursSince(dateStr: string): number {
const now = Date.now()
const then = new Date(dateStr).getTime()
return Math.round((now - then) / 3600000)
}
+113 -27
View File
@@ -1,49 +1,79 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* NexusLayout — V2 Dashboard Shell * NexusLayout — gemeinsame Shell für ALLE Seiten
* Flex row, 100vh, overflow hidden. *
* Sidebar (248px) + Main (flex:1, flex-column) * Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
* Mobile: Sidebar als Overlay mit Hamburger-Toggle * Content bekommt die volle restliche Fläche:
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
* - alle anderen: scrollbarer Container mit Seiten-Padding
*
* Die Live-Verbindung (SSE) gehört der Shell — EINE Verbindung für die
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
*/ */
import { ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import { useDashboardStore } from '../stores/dashboard' import { useAuthStore } from '../stores/auth'
import { useLiveSyncStore } from '../stores/liveSync'
import { useNotificationStore } from '../stores/notifications'
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 { svg } from '../composables/icons'
const dashboardStore = useDashboardStore() const route = useRoute()
const auth = useAuthStore()
const liveSync = useLiveSyncStore()
const notificationStore = useNotificationStore()
/* ── Mobile Sidebar State ───────────────────────── */ const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
/* ── Mobile Drawer ─────────────────────────────── */
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
function closeMobileMenu() { function closeMobileMenu() {
mobileMenuOpen.value = false mobileMenuOpen.value = false
} }
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
function onVisibilityChange() {
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
liveSync.connect(liveUser.value)
}
}
function onOnline() {
liveSync.reconnectNow()
}
onMounted(() => {
liveSync.connect(liveUser.value)
notificationStore.startPolling()
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('online', onOnline)
})
onUnmounted(() => {
liveSync.disconnect()
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('online', onOnline)
})
</script> </script>
<template> <template>
<div class="nexus-layout"> <div class="nexus-layout">
<GalaxyBackground /> <GalaxyBackground />
<Sidebar
:mobile-open="mobileMenuOpen"
@close="closeMobileMenu"
/>
<!-- Mobile Backdrop --> <div class="rail-slot">
<div <Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
v-if="mobileMenuOpen" </div>
class="mobile-backdrop"
@click="closeMobileMenu" <!-- Mobile: Hamburger + Backdrop -->
></div> <button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
<main class="nexus-main"> <main class="nexus-main">
<Topbar <div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
:connected="dashboardStore.isGatewayConnected"
:status-label="dashboardStore.irisStatusLabel"
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
/>
<div class="nexus-content">
<RouterView /> <RouterView />
</div> </div>
</main> </main>
@@ -59,6 +89,15 @@ function closeMobileMenu() {
position: relative; position: relative;
} }
/* Platzhalter in der Flex-Reihe — die Rail selbst liegt absolut darüber
und kann expandieren, ohne den Content zu verschieben. */
.rail-slot {
width: 68px;
flex: 0 0 68px;
position: relative;
z-index: 2;
}
.nexus-main { .nexus-main {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -70,19 +109,62 @@ function closeMobileMenu() {
.nexus-content { .nexus-content {
flex: 1; flex: 1;
overflow: hidden;
min-height: 0; min-height: 0;
} }
.nexus-content.full-bleed {
overflow: hidden;
display: flex;
flex-direction: column;
}
.nexus-content.full-bleed > :deep(*) {
flex: 1;
min-height: 0;
}
.nexus-content.page-scroll {
overflow-y: auto;
padding: 24px 28px 64px;
}
.mobile-toggle,
.mobile-backdrop { .mobile-backdrop {
display: none; display: none;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.rail-slot {
width: 0;
flex: 0 0 0;
}
.nexus-main { .nexus-main {
width: 100%; width: 100%;
} }
.mobile-toggle {
display: grid;
place-items: center;
position: fixed;
top: 12px;
left: 12px;
z-index: 90;
width: 40px;
height: 40px;
border: 1px solid var(--line-2);
border-radius: 12px;
background: var(--glass);
backdrop-filter: blur(12px);
color: var(--tx-2);
cursor: pointer;
}
.mobile-toggle :deep(svg) {
width: 19px;
height: 19px;
}
.mobile-backdrop { .mobile-backdrop {
display: block; display: block;
position: fixed; position: fixed;
@@ -90,5 +172,9 @@ function closeMobileMenu() {
z-index: 99; z-index: 99;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
} }
.nexus-content.page-scroll {
padding: 60px 16px 48px;
}
} }
</style> </style>
+15 -19
View File
@@ -19,31 +19,27 @@ const routes = [
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } }, { path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
{ path: '/', redirect: '/dashboard' }, { path: '/', redirect: '/dashboard' },
// V2 Dashboard (neues NexusLayout + FlowBoard) // Eine Shell für alle Seiten (Rail-Navigation, app-weiter Live-Sync)
{ {
path: '/dashboard', path: '/',
component: NexusLayout, component: NexusLayout,
children: [ children: [
{ path: '', name: 'Dashboard', component: FlowBoard }, { path: 'dashboard', name: 'Dashboard', component: FlowBoard, meta: { fullBleed: true } },
{ path: 'agents', name: 'Agents', component: AgentsIndexView },
{ path: 'agents/:id', name: 'AgentDetail', component: AgentDetailView },
{ path: 'tasks', name: 'Task Board', component: TaskBoardView },
{ path: 'tasks/:id', name: 'TaskDetail', component: TaskDetailView },
{ path: 'memory', name: 'Memory', component: MemoryView },
{ path: 'docs', name: 'Docs', component: DocsView },
{ path: 'calendar', name: 'Calendar', component: CalendarView },
{ path: 'notifications', name: 'Notifications', component: NotificationsView },
{ path: 'incidents', name: 'Incidents', component: IncidentsView },
{ path: 'security', name: 'Security', component: SecurityView },
{ path: 'projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
{ path: 'settings', name: 'Settings', component: SettingsView },
], ],
}, },
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
{ path: '/projects', name: 'Projects', component: { template: '' } },
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
{ path: '/models', name: 'Models', component: { template: '' } },
{ path: '/activity', name: 'Activity', component: { template: '' } },
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }, { path: '/:pathMatch(.*)*', redirect: '/dashboard' },
] ]
-172
View File
@@ -1,172 +0,0 @@
import { defineStore } from 'pinia'
import { openDashboardLiveStream } from '../services/live'
import type { BoardGroup, DashboardTaskDto } from './tasks'
import type { NotificationItem } from './notifications'
import type { TaskItem } from '../components/dashboard/v2/types'
import { useTaskStore } from './tasks'
import { useNotificationStore } from './notifications'
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
interface NotificationSnapshotDto {
notifications: NotificationItem[]
unreadCount: number
forUser: string
}
interface DashboardLiveSnapshotDto {
board: BoardGroup
notifications: NotificationSnapshotDto
cursor: LiveCursorDto
}
function isBoardGroup(value: unknown): value is BoardGroup {
const v = value as BoardGroup
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
}
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
}
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
return {
id: t.id,
title: t.title,
agent: t.assignedTo ?? '—',
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
detail: t.detail,
source: t.source,
}
}
export const useLiveSyncStore = defineStore('liveSync', {
state: () => ({
connected: false,
connecting: false,
lastEventAt: null as string | null,
lastHeartbeatAt: null as string | null,
error: null as string | null,
controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
mode: 'polling' as 'polling' | 'live',
lastSequence: 0,
reconnectAttempts: 0,
}),
getters: {
liveIndicatorLabel: (state) => {
if (state.connecting) return 'Verbinde…'
if (state.connected) return `Live · #${state.lastSequence}`
return state.mode === 'polling' ? 'Polling' : 'Offline'
},
connectionHealth: (state) => {
if (state.connected) return 'healthy'
if (state.connecting) return 'connecting'
return 'degraded'
},
},
actions: {
async connect(forUser = 'bao') {
if (this.connecting || this.connected) return
this.connecting = true
this.error = null
this.controller = new AbortController()
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
try {
const stream = await openDashboardLiveStream((event, data) => {
this.lastEventAt = new Date().toISOString()
if (event === 'heartbeat') {
const cursor = data as LiveCursorDto
this.lastHeartbeatAt = cursor.timestamp
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
return
}
if (event === 'snapshot') {
const snapshot = data as DashboardLiveSnapshotDto
taskStore.board = snapshot.board
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
notificationStore.notifications = snapshot.notifications.notifications
notificationStore.unreadCount = snapshot.notifications.unreadCount
this.lastSequence = snapshot.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
return
}
const eventDto = data as DashboardLiveEventDto
this.applyEnvelope(eventDto.envelope, forUser)
this.lastSequence = eventDto.cursor.sequence
this.connected = true
this.mode = 'live'
this.reconnectAttempts = 0
taskStore.stopBoardPolling()
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed
} catch (error) {
if (this.controller?.signal.aborted) return
console.warn('[liveSync] stream failed, falling back to polling', error)
this.error = 'Live updates unavailable'
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally {
this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) {
this.mode = 'polling'
}
}
},
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
const taskStore = useTaskStore()
const notificationStore = useNotificationStore()
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
taskStore.board = envelope.payload
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
return
}
if (envelope.type === 'notifications.snapshot') {
const snapshot = envelope.payload as NotificationSnapshotDto
if (snapshot.forUser !== forUser) return
notificationStore.notifications = snapshot.notifications
notificationStore.unreadCount = snapshot.unreadCount
}
},
disconnect() {
this.controller?.abort()
this.controller = null
this.connected = false
this.connecting = false
this.mode = 'polling'
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
},
scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect(forUser)
}, delay)
},
},
})
+49 -10
View File
@@ -50,9 +50,11 @@ export const useLiveSyncStore = defineStore('liveSync', {
error: null as string | null, error: null as string | null,
controller: null as AbortController | null, controller: null as AbortController | null,
reconnectTimer: null as ReturnType<typeof setTimeout> | null, reconnectTimer: null as ReturnType<typeof setTimeout> | null,
watchdogTimer: null as ReturnType<typeof setInterval> | null,
mode: 'polling' as 'polling' | 'live', mode: 'polling' as 'polling' | 'live',
lastSequence: 0, lastSequence: 0,
reconnectAttempts: 0, reconnectAttempts: 0,
forUser: 'bao',
}), }),
getters: { getters: {
@@ -73,7 +75,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
if (this.connecting || this.connected) return if (this.connecting || this.connected) return
this.connecting = true this.connecting = true
this.error = null this.error = null
this.forUser = forUser
this.controller = new AbortController() this.controller = new AbortController()
this.startWatchdog()
const taskStore = useTaskStore() const taskStore = useTaskStore()
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
@@ -113,19 +117,51 @@ export const useLiveSyncStore = defineStore('liveSync', {
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null }) }, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
await stream.closed await stream.closed
// Stream „sauber" beendet (Proxy-Timeout, Server-Neustart, Netzwechsel):
// muss genauso wie ein Fehler behandelt werden — sonst bleibt connected=true
// hängen, Polling ist gestoppt und die Seite erhält nie wieder Updates.
} catch (error) { } catch (error) {
if (this.controller?.signal.aborted) return if (!this.controller?.signal.aborted) {
console.warn('[liveSync] stream failed, falling back to polling', error) console.warn('[liveSync] stream failed', error)
this.error = 'Live updates unavailable' this.error = 'Live updates unavailable'
this.connected = false }
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
} finally { } finally {
this.connecting = false this.connecting = false
if (!this.controller?.signal.aborted && !this.connected) { }
this.mode = 'polling'
if (this.controller?.signal.aborted) return
this.connected = false
this.mode = 'polling'
taskStore.startBoardPolling()
this.scheduleReconnect(forUser)
},
/** Erzwingt einen frischen Stream (Watchdog / visibilitychange / online). */
reconnectNow() {
const forUser = this.forUser
this.disconnect()
this.connect(forUser)
},
startWatchdog() {
if (this.watchdogTimer) return
this.watchdogTimer = setInterval(() => {
if (!this.connected || !this.lastEventAt) return
// Heartbeat kommt alle 20s — >65s Stille heißt: Verbindung ist tot,
// auch wenn der Browser den fetch-Stream noch für offen hält.
const silentMs = Date.now() - new Date(this.lastEventAt).getTime()
if (silentMs > 65000) {
console.warn('[liveSync] heartbeat timeout, reconnecting')
this.reconnectNow()
} }
}, 15000)
},
stopWatchdog() {
if (this.watchdogTimer) {
clearInterval(this.watchdogTimer)
this.watchdogTimer = null
} }
}, },
@@ -148,6 +184,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
}, },
disconnect() { disconnect() {
this.stopWatchdog()
this.controller?.abort() this.controller?.abort()
this.controller = null this.controller = null
this.connected = false this.connected = false
@@ -161,7 +198,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
scheduleReconnect(forUser = 'bao') { scheduleReconnect(forUser = 'bao') {
if (this.reconnectTimer) return if (this.reconnectTimer) return
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1)) // Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
// danach sanft hochstaffeln bis 30s.
const delay = this.reconnectAttempts === 0 ? 1000 : Math.min(30000, 5000 * this.reconnectAttempts)
this.reconnectAttempts += 1 this.reconnectAttempts += 1
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null this.reconnectTimer = null
+36
View File
@@ -30,6 +30,7 @@ export interface DashboardTaskDto {
lastActivityMessage?: string | null lastActivityMessage?: string | null
lastActivityAt?: string | null lastActivityAt?: string | null
childTasks?: DashboardTaskDto[] | null childTasks?: DashboardTaskDto[] | null
doneChildTaskCount?: number
childTaskCount?: number childTaskCount?: number
openChildTaskCount?: number openChildTaskCount?: number
hasVisibleDelegation?: boolean hasVisibleDelegation?: boolean
@@ -215,6 +216,29 @@ export const useTaskStore = defineStore('tasks', {
} }
}, },
/* ── API: Review abnehmen (Review → Done) ─────── */
async approveReview(id: string) {
const res = await apiFetch(`/api/dashboard/tasks/${id}/approve`, { method: 'POST' })
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `HTTP ${res.status}`)
}
await this.fetchBoard()
},
/* ── API: Änderung anfordern (Review → Zielspalte) ── */
async requestChanges(id: string, comment: string, targetState = 'In progress') {
const res = await apiFetch(`/api/dashboard/tasks/${id}/request-changes`, {
method: 'POST',
body: JSON.stringify({ comment, targetState }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `HTTP ${res.status}`)
}
await this.fetchBoard()
},
/* ── API: Create task ─────────────────────────── */ /* ── API: Create task ─────────────────────────── */
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) { async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
try { try {
@@ -297,6 +321,18 @@ export const useTaskStore = defineStore('tasks', {
} }
}, },
/* ── API: Kommentar/Aktivität an Task posten ──── */
async postTaskActivity(id: string, message: string, type = 'comment') {
const res = await apiFetch(`/api/dashboard/tasks/${id}/activity`, {
method: 'POST',
body: JSON.stringify({ message, type }),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || `HTTP ${res.status}`)
}
},
/* ── API: Fetch agent workflow overview ──────── */ /* ── API: Fetch agent workflow overview ──────── */
async fetchAgentOverview(staleHours = 2) { async fetchAgentOverview(staleHours = 2) {
this.agentOverviewLoading = true this.agentOverviewLoading = true
@@ -18,7 +18,6 @@ import { useAgentStore } from '../../stores/agents'
import { useChatStore } from '../../stores/chat' import { useChatStore } from '../../stores/chat'
import { useDashboardStore } from '../../stores/dashboard' import { useDashboardStore } from '../../stores/dashboard'
import { useTaskStore } from '../../stores/tasks' import { useTaskStore } from '../../stores/tasks'
import { useLiveSyncStore } from '../../stores/liveSync'
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'
@@ -31,7 +30,6 @@ const agentStore = useAgentStore()
const chatStore = useChatStore() const chatStore = useChatStore()
const dashboardStore = useDashboardStore() const dashboardStore = useDashboardStore()
const taskStore = useTaskStore() const taskStore = useTaskStore()
const liveSyncStore = useLiveSyncStore()
const router = useRouter() const router = useRouter()
const { const {
@@ -70,7 +68,6 @@ onMounted(() => {
dashboardStore.startPolling() dashboardStore.startPolling()
taskStore.startPolling() taskStore.startPolling()
taskStore.startBoardPolling() taskStore.startBoardPolling()
liveSyncStore.connect()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -79,7 +76,6 @@ onUnmounted(() => {
dashboardStore.stopPolling() dashboardStore.stopPolling()
taskStore.stopPolling() taskStore.stopPolling()
taskStore.stopBoardPolling() taskStore.stopBoardPolling()
liveSyncStore.disconnect()
}) })
</script> </script>
-4
View File
@@ -2,12 +2,10 @@
import { onMounted, onUnmounted, computed } from 'vue' import { onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useNotificationStore } from '../stores/notifications' import { useNotificationStore } from '../stores/notifications'
import { useLiveSyncStore } from '../stores/liveSync'
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue' import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
const store = useNotificationStore() const store = useNotificationStore()
const router = useRouter() const router = useRouter()
const liveSyncStore = useLiveSyncStore()
const sortedNotifications = computed(() => { const sortedNotifications = computed(() => {
return [...store.notifications].sort( return [...store.notifications].sort(
@@ -55,12 +53,10 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
onMounted(() => { onMounted(() => {
store.startListPolling() store.startListPolling()
liveSyncStore.connect()
}) })
onUnmounted(() => { onUnmounted(() => {
store.stopListPolling() store.stopListPolling()
liveSyncStore.disconnect()
}) })
</script> </script>
File diff suppressed because it is too large Load Diff