Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ad8c956eb |
+2
-2
@@ -19,7 +19,7 @@ JWT_AUDIENCE=nexus-web
|
|||||||
BOOTSTRAP_OWNER_EMAIL=***
|
BOOTSTRAP_OWNER_EMAIL=***
|
||||||
|
|
||||||
# ── OpenClaw Integration ────────────────────────────────
|
# ── OpenClaw Integration ────────────────────────────────
|
||||||
# Internal Docker-DNS URL of the OpenClaw gateway
|
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
||||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=***
|
OPENCLAW_GATEWAY_TOKEN=***
|
||||||
OPENCLAW_GATEWAY_PASSWORD=***
|
OPENCLAW_GATEWAY_PASSWORD=***
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -60,8 +59,8 @@ 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=${BOOTSTRAP_OWNER_EMAIL}
|
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN:-}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
NEXUS_VERSION=${VERSION}
|
NEXUS_VERSION=${VERSION}
|
||||||
@@ -107,25 +106,37 @@ git archive --format=tar HEAD | docker run --rm -i \
|
|||||||
'
|
'
|
||||||
|
|
||||||
# ── Sanitized agents config for Nexus (no secrets) ──
|
# ── Sanitized agents config for Nexus (no secrets) ──
|
||||||
echo "Generating sanitized agents config for Nexus"
|
echo "Generating sanitized agents config for Nexus (no secrets from openclaw.json)"
|
||||||
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
|
AGENTS_SANITIZED_PATH="/home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json"
|
||||||
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
|
OPENCLAW_CONFIG="/home/projekte_bao/openclaw/data/openclaw/openclaw.json"
|
||||||
if [ -f "$OPENCLAW_CONFIG" ]; then
|
OPENCLAW_CONFIG_DIR="/home/projekte_bao/openclaw/data/openclaw"
|
||||||
|
|
||||||
|
# Use Docker to read openclaw.json (runner doesn't have direct host fs access)
|
||||||
|
if docker run --rm \
|
||||||
|
-v "$OPENCLAW_CONFIG:/input/openclaw.json:ro" \
|
||||||
|
-v "$OPENCLAW_CONFIG_DIR:/output" \
|
||||||
|
python:3.12-alpine \
|
||||||
python3 -c "
|
python3 -c "
|
||||||
import json, sys
|
import json, sys, os
|
||||||
with open('$OPENCLAW_CONFIG') as f:
|
config_path = '/input/openclaw.json'
|
||||||
|
output_path = '/output/agents-sanitized.json'
|
||||||
|
if not os.path.isfile(config_path):
|
||||||
|
print(f'WARNING: openclaw.json not found at {config_path} — agents-sanitized.json NOT generated', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
with open(config_path) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
agents = data.get('agents')
|
agents = data.get('agents')
|
||||||
if agents is None:
|
if agents is None:
|
||||||
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
|
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
with open('$AGENTS_SANITIZED_PATH', 'w') as f:
|
with open(output_path, 'w') as f:
|
||||||
json.dump({'agents': agents}, f, indent=2)
|
json.dump({'agents': agents}, f, indent=2)
|
||||||
"
|
f.write('\n')
|
||||||
chmod 644 "$AGENTS_SANITIZED_PATH" 2>/dev/null || true
|
print(f'Sanitized agents config written ({len(agents.get(\"list\", []))} agents)')
|
||||||
|
" 2>&1; then
|
||||||
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
|
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
|
||||||
else
|
else
|
||||||
echo "WARNING: openclaw.json not found at $OPENCLAW_CONFIG — agents-sanitized.json NOT generated" >&2
|
echo "WARNING: Failed to generate agents-sanitized.json — Nexus will use fallback agent IDs" >&2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building and starting Docker compose stack"
|
echo "Building and starting Docker compose stack"
|
||||||
@@ -140,14 +151,7 @@ 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"
|
||||||
|
|
||||||
@@ -170,15 +174,9 @@ echo "Checking live health"
|
|||||||
retry=0
|
retry=0
|
||||||
while [ "$retry" -lt 6 ]; do
|
while [ "$retry" -lt 6 ]; do
|
||||||
retry=$((retry + 1))
|
retry=$((retry + 1))
|
||||||
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
|
if curl -fsS --max-time 10 "$BASE_URL/health" >/dev/null; then
|
||||||
case "$health_body" in
|
echo "Health check passed"
|
||||||
'{"status":"Healthy"'*)
|
break
|
||||||
echo "Health check passed"
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
if [ -n "$health_body" ]; then
|
|
||||||
echo "Health endpoint is reachable but not healthy: $health_body" >&2
|
|
||||||
fi
|
fi
|
||||||
if [ "$retry" -eq 6 ]; then
|
if [ "$retry" -eq 6 ]; then
|
||||||
echo "Health check failed" >&2
|
echo "Health check failed" >&2
|
||||||
@@ -220,45 +218,9 @@ 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"
|
||||||
|
|||||||
@@ -112,11 +112,9 @@ jobs:
|
|||||||
JWT_ISSUER=nexus
|
JWT_ISSUER=nexus
|
||||||
JWT_AUDIENCE=nexus-web
|
JWT_AUDIENCE=nexus-web
|
||||||
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
BOOTSTRAP_OWNER_EMAIL=vmbao62@hotmail.de
|
||||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||||
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
|
||||||
OPENCLAW_GATEWAY_PASSWORD=
|
OPENCLAW_GATEWAY_PASSWORD=
|
||||||
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
|
|
||||||
NEXUS_GIT_SHA=$(git rev-parse HEAD)
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 600 "${ENV_TMPFILE}"
|
chmod 600 "${ENV_TMPFILE}"
|
||||||
@@ -129,38 +127,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
git archive --format=tar HEAD | docker run --rm -i \
|
docker run --rm \
|
||||||
|
-v "${{ gitea.workspace }}:/src:ro" \
|
||||||
-v "${DEPLOY_PATH}:/dest" \
|
-v "${DEPLOY_PATH}:/dest" \
|
||||||
alpine:latest \
|
alpine:latest \
|
||||||
sh -c '
|
sh -c "
|
||||||
set -eu
|
cd /src && \
|
||||||
dest_owner="$(stat -c "%u:%g" /dest)"
|
find . -mindepth 1 -maxdepth 1 \
|
||||||
mkdir -p /src-snapshot
|
! -name .git \
|
||||||
tar -xf - -C /src-snapshot
|
-exec cp -r {} /dest/ \; && \
|
||||||
|
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||||
is_protected_path() {
|
chown -R \"\$DEST_OWNER\" /dest
|
||||||
case "$1" in
|
"
|
||||||
./.git|./.env|./.env.*|./data|./logs|./backups|./tmp|./uploads|./storage)
|
|
||||||
return 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
return 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
cd /dest
|
|
||||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
|
||||||
if ! is_protected_path "$path"; then rm -rf "$path"; fi
|
|
||||||
done
|
|
||||||
|
|
||||||
cd /src-snapshot
|
|
||||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
|
||||||
if ! is_protected_path "$path"; then cp -a "$path" /dest/; fi
|
|
||||||
done
|
|
||||||
|
|
||||||
chown -R "$dest_owner" /dest
|
|
||||||
'
|
|
||||||
|
|
||||||
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||||
|
|
||||||
@@ -173,18 +151,16 @@ jobs:
|
|||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||||
|
-v "/tmp:/tmp-host:ro" \
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
-w /workspace/nexus \
|
-w /workspace/nexus \
|
||||||
-i \
|
|
||||||
docker:cli \
|
docker:cli \
|
||||||
sh -c '
|
sh -c "
|
||||||
set -eu
|
set -e
|
||||||
umask 077
|
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||||
cat > /tmp/nexus-rollback-env
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||||
trap '\''rm -f /tmp/nexus-rollback-env'\'' EXIT INT TERM
|
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||||
docker compose --env-file /tmp/nexus-rollback-env build --no-cache
|
"
|
||||||
docker compose --env-file /tmp/nexus-rollback-env up -d --wait --force-recreate
|
|
||||||
' < "${ENV_TMPFILE}"
|
|
||||||
|
|
||||||
echo "✅ Rollback redeploy completed"
|
echo "✅ Rollback redeploy completed"
|
||||||
|
|
||||||
@@ -210,14 +186,11 @@ jobs:
|
|||||||
WAIT=1
|
WAIT=1
|
||||||
while [ $RETRY -lt $MAX ]; do
|
while [ $RETRY -lt $MAX ]; do
|
||||||
RETRY=$((RETRY + 1))
|
RETRY=$((RETRY + 1))
|
||||||
HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
|
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||||
case "$HEALTH_BODY" in
|
echo ""
|
||||||
'{"status":"Healthy"'*)
|
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||||
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
exit 0
|
||||||
exit 0
|
fi
|
||||||
;;
|
|
||||||
esac
|
|
||||||
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
|
|
||||||
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||||
sleep $WAIT
|
sleep $WAIT
|
||||||
NEXT=$((WAIT + RETRY))
|
NEXT=$((WAIT + RETRY))
|
||||||
|
|||||||
@@ -39,6 +39,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -131,8 +131,7 @@ 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);
|
||||||
|
|
||||||
@@ -144,51 +143,7 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
|
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
|
||||||
{
|
|
||||||
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();
|
||||||
@@ -199,21 +154,20 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||||
{
|
{
|
||||||
StalledMinutes = 45,
|
StaleHours = 4,
|
||||||
IntervalMinutes = 10
|
IntervalMinutes = 30
|
||||||
}),
|
}),
|
||||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||||
|
|
||||||
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
|
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
|
Assert.Equal(1, fakeRecoveryService.CallCount);
|
||||||
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
|
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
|
||||||
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
|
Assert.Equal(7, resetCount);
|
||||||
Assert.Equal(7, flaggedCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
|
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
|
||||||
{
|
{
|
||||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||||
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -227,8 +181,8 @@ public sealed class StaleTaskRecoveryTests
|
|||||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||||
{
|
{
|
||||||
StalledMinutes = 40,
|
StaleHours = 2,
|
||||||
IntervalMinutes = 10
|
IntervalMinutes = 30
|
||||||
}),
|
}),
|
||||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||||
|
|
||||||
@@ -237,8 +191,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.FlagCallCount >= 1);
|
Assert.True(fakeRecoveryService.CallCount >= 1);
|
||||||
Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
|
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -371,52 +325,19 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService
|
|||||||
|
|
||||||
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
||||||
{
|
{
|
||||||
public int FlagCallCount { get; private set; }
|
public int CallCount { 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)
|
||||||
{
|
{
|
||||||
ResetCallCount++;
|
CallCount++;
|
||||||
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;
|
||||||
|
|||||||
@@ -440,8 +440,7 @@ 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,
|
||||||
|
|||||||
@@ -236,59 +236,37 @@ 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));
|
||||||
|
|
||||||
// PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
|
while (!ct.IsCancellationRequested)
|
||||||
// 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
|
|
||||||
{
|
{
|
||||||
while (!ct.IsCancellationRequested)
|
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||||
|
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||||
|
|
||||||
|
if (completed == readTask)
|
||||||
{
|
{
|
||||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
var envelope = await readTask;
|
||||||
|
if (envelope.Type == "notifications.snapshot")
|
||||||
if (completed == readTask)
|
|
||||||
{
|
{
|
||||||
var envelope = await readTask;
|
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||||
readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||||
|
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||||
if (envelope.Type == "notifications.snapshot")
|
continue;
|
||||||
{
|
envelope = envelope with { Payload = snapshot };
|
||||||
var snapshot = envelope.Payload as NotificationSnapshotDto
|
|
||||||
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
|
||||||
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
envelope = envelope with { Payload = snapshot };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope.Type == "tasks.board.snapshot")
|
|
||||||
{
|
|
||||||
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
|
||||||
}
|
|
||||||
|
|
||||||
await WriteEventAsync("update", new DashboardLiveEventDto(
|
|
||||||
envelope,
|
|
||||||
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
|
||||||
}
|
}
|
||||||
else
|
|
||||||
|
if (envelope.Type == "tasks.board.snapshot")
|
||||||
{
|
{
|
||||||
var ticked = await heartbeatTask;
|
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||||
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
|
||||||
if (!ticked) break;
|
|
||||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||||
|
envelope,
|
||||||
|
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||||
|
}
|
||||||
|
else if (await heartbeatTask)
|
||||||
|
{
|
||||||
|
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
// Client hat die Verbindung beendet — normal.
|
|
||||||
}
|
|
||||||
catch (System.Threading.Channels.ChannelClosedException)
|
|
||||||
{
|
|
||||||
// Subscription serverseitig geschlossen — Stream regulär beenden.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,52 +300,6 @@ 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).
|
||||||
@@ -399,7 +331,19 @@ 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)
|
||||||
|
|||||||
@@ -99,8 +99,7 @@ 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(
|
||||||
@@ -184,11 +183,6 @@ 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>
|
||||||
|
|||||||
@@ -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")
|
||||||
?? "/etc/nexus/agents-sanitized.json";
|
?? "/home/node/.openclaw/openclaw.json";
|
||||||
|
|
||||||
if (!File.Exists(path))
|
if (!File.Exists(path))
|
||||||
return BuildFallbackConfigs();
|
return BuildFallbackConfigs();
|
||||||
|
|||||||
@@ -2,9 +2,5 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,12 +33,9 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration
|
|||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load agent IDs from sanitized agents config (no secrets)
|
// Load agent IDs from openclaw.json config
|
||||||
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 sanitized agents config (no secrets).
|
/// Loads agent IDs from the OpenClaw config file (openclaw.json).
|
||||||
/// 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")
|
||||||
?? "/etc/nexus/agents-sanitized.json";
|
?? "/home/node/.openclaw/openclaw.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")
|
||||||
?? "/etc/nexus/agents-sanitized.json";
|
?? "/home/node/.openclaw/openclaw.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 flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
|
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
|
||||||
if (flaggedCount > 0)
|
if (resetCount > 0)
|
||||||
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
|
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -37,10 +37,10 @@ public sealed class StaleTaskRecoveryBackgroundService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> RunWatchdogOnceAsync(CancellationToken ct = default)
|
public async Task<int> RunRecoveryOnceAsync(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.FlagStalledInProgressTasksAsync(optionsMonitor.CurrentValue.GetStalledThreshold(), ct);
|
return await recoveryService.ResetStaleInProgressTasksAsync(optionsMonitor.CurrentValue.GetStaleThreshold(), ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,8 @@ 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));
|
||||||
|
|
||||||
|
|||||||
@@ -7,81 +7,8 @@ namespace Nexus.Api.Services;
|
|||||||
public sealed class StaleTaskRecoveryService(
|
public sealed class StaleTaskRecoveryService(
|
||||||
ITaskRepository taskRepository,
|
ITaskRepository taskRepository,
|
||||||
IActivityRepository activityRepository,
|
IActivityRepository activityRepository,
|
||||||
ILiveUpdateService liveUpdateService,
|
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
|
||||||
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;
|
||||||
@@ -153,8 +80,88 @@ 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 activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
|
var taskIds = allTasks.Select(task => task.Id).ToList();
|
||||||
return TaskService.BuildMasterBoard(allTasks, activity);
|
var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct);
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -183,9 +190,20 @@ 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)
|
||||||
{
|
{
|
||||||
if (duration.TotalHours >= 1)
|
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
||||||
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
|
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
|
||||||
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,7 +214,13 @@ 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)
|
||||||
@@ -244,6 +250,13 @@ 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,
|
||||||
|
|||||||
@@ -422,19 +422,6 @@ 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>();
|
||||||
@@ -444,10 +431,7 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
foreach (var task in all)
|
foreach (var task in all)
|
||||||
{
|
{
|
||||||
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
|
var dto = MapToDtoWithChildren(task, all, activity);
|
||||||
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;
|
||||||
@@ -506,78 +490,6 @@ 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);
|
||||||
@@ -595,46 +507,28 @@ 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 static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
|
||||||
{
|
{
|
||||||
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();
|
||||||
|
|
||||||
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
|
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
|
||||||
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 = childTasks.Count,
|
ChildTaskCount = childDtos.Count,
|
||||||
OpenChildTaskCount = openChildTaskCount,
|
OpenChildTaskCount = openChildTaskCount,
|
||||||
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
|
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
||||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,8 @@
|
|||||||
"RefreshTokenExpirationDays": 7
|
"RefreshTokenExpirationDays": 7
|
||||||
},
|
},
|
||||||
"TaskRecovery": {
|
"TaskRecovery": {
|
||||||
"StalledMinutes": 40,
|
"StaleHours": 2,
|
||||||
"IntervalMinutes": 10,
|
"IntervalMinutes": 30
|
||||||
"StaleHours": 2
|
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-14
@@ -2,15 +2,6 @@ name: nexus
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
# WAL-Archivierung bleibt deaktiviert, bis ein verwaltetes Off-Server-Ziel
|
|
||||||
# mit Retention und Restore-Test existiert. Ein lokales Endlosarchiv ist
|
|
||||||
# kein Backup und kann bei Fehlern pg_wal ungebremst wachsen lassen.
|
|
||||||
command:
|
|
||||||
- postgres
|
|
||||||
- -c
|
|
||||||
- archive_mode=off
|
|
||||||
- -c
|
|
||||||
- archive_command=
|
|
||||||
restart: always
|
restart: always
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
@@ -59,17 +50,16 @@ services:
|
|||||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||||
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
|
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
|
||||||
# Initial owner password is generated once at first seed and then lives only in the DB.
|
# Initial owner password is generated once at first seed and then lives only in the DB.
|
||||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
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:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
restart: true
|
restart: true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
||||||
@@ -78,7 +68,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
volumes:
|
volumes:
|
||||||
- /home/projekte_bao/openclaw/data/openclaw/agents-sanitized.json:/etc/nexus/agents-sanitized.json:ro
|
- /home/projekte_bao/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.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
|
||||||
@@ -116,7 +106,7 @@ services:
|
|||||||
- "127.0.0.1:18880:80"
|
- "127.0.0.1:18880:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_started
|
||||||
restart: true
|
restart: true
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren
|
|||||||
│ │
|
│ │
|
||||||
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
|
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
|
||||||
└──────────────┬──────────────────────────────┬────────────────────┘
|
└──────────────┬──────────────────────────────┬────────────────────┘
|
||||||
│ openclaw-gateway-bao:18789 │
|
│ host.docker.internal:18789 │
|
||||||
│ (internes Docker-DNS) │
|
│ (Gateway loopback/lan) │
|
||||||
▼ │
|
▼ │
|
||||||
┌──────────────────────────────┐ │
|
┌──────────────────────────────┐ │
|
||||||
│ OpenClaw Gateway Container │ │
|
│ OpenClaw Gateway Container │ │
|
||||||
@@ -199,7 +199,7 @@ Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
|
|||||||
```
|
```
|
||||||
POST /api/v1/operations/snapshot
|
POST /api/v1/operations/snapshot
|
||||||
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
|
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
|
||||||
→ POST http://openclaw-gateway-bao:18789/tools/invoke
|
→ POST http://host.docker.internal:18789/tools/invoke
|
||||||
Authorization: Bearer <Gateway-Password>
|
Authorization: Bearer <Gateway-Password>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ POST /api/v1/operations/snapshot
|
|||||||
|
|
||||||
### 5.2 Docker-Netzwerk & Gateway-Bind
|
### 5.2 Docker-Netzwerk & Gateway-Bind
|
||||||
|
|
||||||
**Aktueller Stand (2026-07-09):**
|
**Aktuelles Problem:**
|
||||||
```
|
```
|
||||||
compose.yaml:
|
compose.yaml:
|
||||||
api:
|
api:
|
||||||
@@ -228,27 +228,32 @@ compose.yaml:
|
|||||||
- host.docker.internal:host-gateway
|
- host.docker.internal:host-gateway
|
||||||
networks:
|
networks:
|
||||||
- nexus
|
- nexus
|
||||||
- openclaw_default
|
- openclaw_default ← API-Container ist im Gateway-Netzwerk
|
||||||
|
|
||||||
Gateway-Konfiguration:
|
Gateway-Konfiguration:
|
||||||
gateway.bind: "lan"
|
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER
|
||||||
|
|
||||||
Nexus-Konfiguration:
|
|
||||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Ergebnis:**
|
**Ergebnis:**
|
||||||
- Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
|
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt
|
||||||
- Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
|
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback
|
||||||
- Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
|
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt
|
||||||
|
|
||||||
Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
|
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):**
|
||||||
|
```json5
|
||||||
Produktive Einstellung:
|
// openclaw.json
|
||||||
```yaml
|
{
|
||||||
Integrations__OpenClaw__BaseUrl: http://openclaw-gateway-bao:18789
|
gateway: {
|
||||||
|
bind: "lan" // war "loopback"
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
Beide Container müssen Mitglied im `openclaw_default`-Netzwerk sein.
|
|
||||||
|
Alternativ: API-Container über Gateway-Container-Namen ansprechen:
|
||||||
|
```yaml
|
||||||
|
Integrations__OpenClaw__BaseUrl: http://openclaw_gateway:18789
|
||||||
|
```
|
||||||
|
(Vorausgesetzt der Gateway-Container heißt `openclaw_gateway` und ist im `openclaw_default` Netzwerk)
|
||||||
|
|
||||||
### 5.3 MCP-artige Integration: Bewertung
|
### 5.3 MCP-artige Integration: Bewertung
|
||||||
|
|
||||||
|
|||||||
@@ -290,30 +290,30 @@ The Nexus compose.yaml already includes the full integration infrastructure:
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
api:
|
api:
|
||||||
|
extra_hosts:
|
||||||
|
- host.docker.internal:host-gateway
|
||||||
environment:
|
environment:
|
||||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||||
networks:
|
|
||||||
- nexus
|
|
||||||
- openclaw_default
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The API container:
|
The API container:
|
||||||
- Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
|
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
|
||||||
- Does not depend on a published host port for the Gateway
|
- Has `extra_hosts` configured for `host.docker.internal`
|
||||||
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
|
- Reads token/password from `.env` via `OPENCLAW_GATEWAY_PASSWORD`
|
||||||
|
|
||||||
### Resolved Routing Issue (2026-07-09)
|
### Known Issue: Gateway Bind = loopback
|
||||||
|
|
||||||
The old `host.docker.internal:18789` route was unreachable because no usable host port was published. The Gateway is now reached directly by its container DNS name.
|
The Gateway binds to `127.0.0.1` (`gateway.bind: "loopback"`). This means it only listens inside the gateway container's loopback interface.
|
||||||
|
|
||||||
| Scenario | Works? | Why |
|
| Scenario | Works? | Why |
|
||||||
|----------|--------|-----|
|
|----------|--------|-----|
|
||||||
| `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
|
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
|
||||||
| `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
|
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
|
||||||
|
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
|
||||||
|
|
||||||
The Gateway must listen on its container interface (`gateway.bind: "lan"`):
|
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@@ -325,8 +325,8 @@ The Gateway must listen on its container interface (`gateway.bind: "lan"`):
|
|||||||
|
|
||||||
**Test command (from Nexus API container):**
|
**Test command (from Nexus API container):**
|
||||||
```bash
|
```bash
|
||||||
curl -s http://openclaw-gateway-bao:18789/
|
curl -s http://host.docker.internal:18789/health
|
||||||
# Expected: HTTP 200 from inside nexus-api-1
|
# Expected: 200 if gateway bind is lan/container IP is reachable
|
||||||
```
|
```
|
||||||
|
|
||||||
### Required .env Vars for Nexus
|
### Required .env Vars for Nexus
|
||||||
|
|||||||
@@ -5,15 +5,6 @@ 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;
|
||||||
|
|||||||
+146
-8
@@ -1,14 +1,152 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
import { computed, onMounted, ref } from 'vue'
|
||||||
* App — nur noch Router-Einstieg + Toasts.
|
import { Activity } from '@lucide/vue'
|
||||||
* Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
* und umschließt alle Seiten außer dem Login.
|
import { useOperationsStore } from './stores/operations'
|
||||||
*/
|
import { useAuthStore } from './stores/auth'
|
||||||
import { RouterView } from 'vue-router'
|
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||||
|
import AppHeader from './components/layout/AppHeader.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 />
|
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
|
||||||
<ToastContainer />
|
<div v-else class="shell">
|
||||||
|
<AppSidebar
|
||||||
|
:active-view="activeView"
|
||||||
|
:mobile-nav-open="mobileNavOpen"
|
||||||
|
:queued-tasks="store.snapshot.metrics.queuedTasks"
|
||||||
|
:incidents="store.snapshot.metrics.incidents"
|
||||||
|
@navigate="navigate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<AppHeader
|
||||||
|
:connected="store.connected"
|
||||||
|
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="content">
|
||||||
|
<RouterView v-if="standaloneViews" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="page-heading">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">MISSION CONTROL</span>
|
||||||
|
<h1>{{ activeView }}</h1>
|
||||||
|
<p>System overview and operational intelligence across Noveria.</p>
|
||||||
|
</div>
|
||||||
|
<button class="refresh" @click="store.refresh()">
|
||||||
|
<Activity :size="15" :class="{ spin: store.loading }" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModuleView
|
||||||
|
:view="activeView"
|
||||||
|
:snapshot="store.snapshot"
|
||||||
|
:routing="store.routing"
|
||||||
|
@create-project="store.createProject"
|
||||||
|
@create-task="store.createTask"
|
||||||
|
@update-task-state="store.updateTaskState"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<ToastContainer />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.shell {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.page-heading h1 { margin: 0; font-size: 18px; }
|
||||||
|
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
|
||||||
|
.eyebrow {
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
color: var(--nx-accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.refresh {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 11px;
|
||||||
|
border: 1px solid var(--nx-line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nx-text-dim);
|
||||||
|
font-size: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
|
||||||
|
|
||||||
|
.spin { animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.kanban { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+80
-108
@@ -83,11 +83,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background:
|
background: hsl(var(--background));
|
||||||
radial-gradient(1100px 700px at 12% -10%, rgba(79, 124, 255, 0.10), transparent 60%),
|
color: hsl(var(--foreground));
|
||||||
radial-gradient(1000px 700px at 95% 8%, rgba(181, 87, 246, 0.09), transparent 60%),
|
|
||||||
var(--space-0, #050410);
|
|
||||||
color: var(--tx, #ece9ff);
|
|
||||||
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||||
'Segoe UI', sans-serif;
|
'Segoe UI', sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -95,10 +92,6 @@ body {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, .font-display {
|
|
||||||
font-family: 'Space Grotesk', 'Manrope', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nexus overrides for existing CSS variables used in dashboard */
|
/* Nexus overrides for existing CSS variables used in dashboard */
|
||||||
:root {
|
:root {
|
||||||
--nx-bg: #080a0f;
|
--nx-bg: #080a0f;
|
||||||
@@ -133,9 +126,9 @@ h1, h2, h3, .font-display {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 22px 14px 14px;
|
padding: 22px 14px 14px;
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid #1a1e27;
|
||||||
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
background: rgba(9, 11, 16, 0.94);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -150,16 +143,15 @@ h1, h2, h3, .font-display {
|
|||||||
height: 35px;
|
height: 35px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: none;
|
border: 1px solid #443d7c;
|
||||||
border-radius: 11px;
|
border-radius: 10px;
|
||||||
background: var(--grad);
|
background: linear-gradient(145deg, #241f44, #12121f);
|
||||||
color: #fff;
|
color: #b8adff;
|
||||||
box-shadow: var(--glow-purple);
|
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand strong {
|
.brand strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
}
|
}
|
||||||
@@ -186,38 +178,36 @@ h1, h2, h3, .font-display {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
padding: 9px 10px;
|
padding: 9px 10px;
|
||||||
border-radius: 10px;
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #8991a1;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav button:hover {
|
.nav button:hover,
|
||||||
color: var(--tx);
|
.nav button.active {
|
||||||
background: rgba(124, 108, 255, 0.08);
|
color: #ececf5;
|
||||||
|
background: var(--nx-accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav button.active {
|
.nav button.active {
|
||||||
color: #fff;
|
box-shadow: inset 2px 0 var(--nx-accent);
|
||||||
background: linear-gradient(90deg, rgba(124, 108, 255, 0.22), rgba(124, 108, 255, 0.04));
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav button i {
|
.nav button i {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid #343947;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(124, 108, 255, 0.10);
|
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-bottom {
|
.sidebar-bottom {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid #1b1f28;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,12 +240,10 @@ h1, h2, h3, .font-display {
|
|||||||
height: 31px;
|
height: 31px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 10px;
|
border-radius: 50%;
|
||||||
background: var(--grad-soft);
|
background: #28243f;
|
||||||
border: 1px solid var(--line-2);
|
color: #bcb3ff;
|
||||||
color: var(--tx);
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
@@ -268,9 +256,9 @@ main {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 30px;
|
padding: 0 30px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid #191d25;
|
||||||
background: rgba(8, 6, 20, 0.5);
|
background: rgba(8, 10, 15, 0.68);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search {
|
.search {
|
||||||
@@ -278,20 +266,19 @@ main {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 8px 12px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid #202530;
|
||||||
border-radius: 11px;
|
border-radius: 7px;
|
||||||
background: rgba(124, 108, 255, 0.06);
|
color: #6f7889;
|
||||||
color: var(--tx-3);
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search kbd {
|
.search kbd {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid #2c313d;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--tx-3);
|
color: #606979;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,20 +293,15 @@ main {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
color: #8c95a5;
|
||||||
color: var(--tx-2);
|
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
background: rgba(124, 108, 255, 0.07);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 4px 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection.live {
|
.connection.live {
|
||||||
color: var(--st-work);
|
color: var(--nx-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection.preview {
|
.connection.preview {
|
||||||
color: var(--st-queue);
|
color: #e6b75d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ask,
|
.ask,
|
||||||
@@ -327,20 +309,13 @@ main {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 8px 13px;
|
padding: 8px 11px;
|
||||||
border: none;
|
border: 1px solid #37315e;
|
||||||
border-radius: 10px;
|
border-radius: 7px;
|
||||||
background: var(--grad);
|
background: #18152a;
|
||||||
color: #fff;
|
color: #c4bbff;
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: var(--glow-purple);
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: filter .16s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ask:hover {
|
|
||||||
filter: brightness(1.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -356,7 +331,7 @@ main {
|
|||||||
|
|
||||||
.eyebrow,
|
.eyebrow,
|
||||||
.kicker {
|
.kicker {
|
||||||
color: var(--a-mid);
|
color: #7065c8;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0.18em;
|
||||||
@@ -376,10 +351,9 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
background: rgba(124, 108, 255, 0.07);
|
border-color: var(--nx-line);
|
||||||
border: 1px solid var(--line-2);
|
background: var(--nx-panel);
|
||||||
box-shadow: none;
|
color: #a5adba;
|
||||||
color: var(--tx-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
@@ -396,7 +370,7 @@ h1 {
|
|||||||
display: none;
|
display: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #aaa4e7;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Keep existing module/layout styles for non-dashboard pages ── */
|
/* ── Keep existing module/layout styles for non-dashboard pages ── */
|
||||||
@@ -409,10 +383,9 @@ h1 {
|
|||||||
|
|
||||||
.metrics article,
|
.metrics article,
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--nx-line);
|
||||||
background: var(--glass);
|
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics article {
|
.metrics article {
|
||||||
@@ -420,7 +393,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metrics span {
|
.metrics span {
|
||||||
color: var(--tx-3);
|
color: #717a8a;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
@@ -434,12 +407,12 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metrics small {
|
.metrics small {
|
||||||
color: var(--tx-3);
|
color: #687181;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metrics small.up {
|
.metrics small.up {
|
||||||
color: var(--st-work);
|
color: #55c995;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
@@ -462,7 +435,7 @@ h1 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-bottom: 15px;
|
padding-bottom: 15px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid #1d222c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-head h2 {
|
.panel-head h2 {
|
||||||
@@ -473,7 +446,7 @@ h1 {
|
|||||||
.panel-head button {
|
.panel-head button {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #8e96a5;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -484,18 +457,18 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge.positive {
|
.badge.positive {
|
||||||
color: var(--st-work);
|
color: var(--nx-green);
|
||||||
background: rgba(61, 220, 151, 0.12);
|
background: rgba(81, 212, 154, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.warning {
|
.badge.warning {
|
||||||
color: var(--st-queue);
|
color: #e7b660;
|
||||||
background: rgba(251, 191, 36, 0.12);
|
background: rgba(231, 182, 96, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge.negative {
|
.badge.negative {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
background: rgba(251, 113, 133, 0.12);
|
background: rgba(225, 110, 117, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-row {
|
.runtime-row {
|
||||||
@@ -510,10 +483,9 @@ h1 {
|
|||||||
height: 45px;
|
height: 45px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--r-sm);
|
border-radius: 9px;
|
||||||
color: var(--a-mid);
|
color: #ad9fff;
|
||||||
background: var(--grad-soft);
|
background: var(--nx-accent-soft);
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-main strong,
|
.runtime-main strong,
|
||||||
@@ -545,7 +517,7 @@ h1 {
|
|||||||
width: 3px;
|
width: 3px;
|
||||||
min-height: 5px;
|
min-height: 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: linear-gradient(var(--a-mid), rgba(124, 108, 255, 0.35));
|
background: linear-gradient(#927fff, #443b7c);
|
||||||
}
|
}
|
||||||
|
|
||||||
.model {
|
.model {
|
||||||
@@ -554,11 +526,11 @@ h1 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 12px 2px;
|
padding: 12px 2px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid #1b2029;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model > span:last-child {
|
.model > span:last-child {
|
||||||
color: var(--tx-3);
|
color: #687181;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,16 +538,16 @@ h1 {
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--st-idle);
|
background: #657083;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.online {
|
.status-dot.online {
|
||||||
background: var(--st-work);
|
background: var(--nx-green);
|
||||||
box-shadow: 0 0 7px rgba(61, 220, 151, 0.4);
|
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.offline {
|
.status-dot.offline {
|
||||||
background: var(--st-block);
|
background: #e16e75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project {
|
.project {
|
||||||
@@ -584,7 +556,7 @@ h1 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid #1b2029;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-letter {
|
.project-letter {
|
||||||
@@ -592,9 +564,9 @@ h1 {
|
|||||||
height: 31px;
|
height: 31px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid #353047;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,7 +581,7 @@ h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project b {
|
.project b {
|
||||||
color: var(--tx-2);
|
color: #838c9c;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,14 +590,14 @@ h1 {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--space-3);
|
background: #242936;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress i {
|
.progress i {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: var(--grad);
|
background: linear-gradient(90deg, #685ac8, #a091ff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event {
|
.event {
|
||||||
@@ -633,7 +605,7 @@ h1 {
|
|||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid #1b2029;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event > span {
|
.event > span {
|
||||||
@@ -641,19 +613,19 @@ h1 {
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--st-idle);
|
background: #657083;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event > span.runtime {
|
.event > span.runtime {
|
||||||
background: var(--st-work);
|
background: var(--nx-green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.event > span.deploy {
|
.event > span.deploy {
|
||||||
background: var(--a-mid);
|
background: #8b7cf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event > span.security {
|
.event > span.security {
|
||||||
background: var(--st-queue);
|
background: #e5ad52;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
@@ -667,7 +639,7 @@ h1 {
|
|||||||
|
|
||||||
.placeholder svg {
|
.placeholder svg {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
color: var(--a-mid);
|
color: #8074d8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder h2 {
|
.placeholder h2 {
|
||||||
|
|||||||
@@ -37,14 +37,6 @@
|
|||||||
--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);
|
||||||
@@ -61,45 +53,6 @@
|
|||||||
--sidebar-w: 248px;
|
--sidebar-w: 248px;
|
||||||
--topbar-h: 62px;
|
--topbar-h: 62px;
|
||||||
--rail-w: 360px;
|
--rail-w: 360px;
|
||||||
|
|
||||||
/* ── Legacy-Aliasse (v1-Views) → V2-Tokens ────────────
|
|
||||||
Ältere Views konsumieren noch die v1-Variablennamen.
|
|
||||||
Nicht anfassen: --border/--accent (shadcn-HSL-Tripel,
|
|
||||||
werden als hsl(var(--…)) konsumiert). */
|
|
||||||
--nx-bg: var(--space-1);
|
|
||||||
--nx-panel: var(--glass);
|
|
||||||
--nx-panel-soft: var(--glass-2);
|
|
||||||
--nx-line: var(--line);
|
|
||||||
--nx-muted: var(--tx-3);
|
|
||||||
--nx-accent: var(--a-mid);
|
|
||||||
--nx-accent-soft: rgba(124, 108, 255, 0.10);
|
|
||||||
--nx-green: var(--st-work);
|
|
||||||
--nx-text: var(--tx);
|
|
||||||
--nx-text-dim: var(--tx-2);
|
|
||||||
--panel: var(--glass);
|
|
||||||
--card-color: var(--glass);
|
|
||||||
--surface: var(--space-2);
|
|
||||||
--surface-raised: var(--space-3);
|
|
||||||
--accent-soft: rgba(124, 108, 255, 0.10);
|
|
||||||
--accent-secondary: var(--a-purple);
|
|
||||||
--text-primary: var(--tx);
|
|
||||||
--text-secondary: var(--tx-2);
|
|
||||||
--text-muted: var(--tx-3);
|
|
||||||
--text-dim: var(--tx-3);
|
|
||||||
|
|
||||||
/* ── State Pill Colors ───────────────────────────── */
|
|
||||||
--pill-backlog: #fde68a;
|
|
||||||
--pill-progress: #86efac;
|
|
||||||
--pill-done: #86efac;
|
|
||||||
--pill-review: #fdba74;
|
|
||||||
--pill-blocked: #fda4af;
|
|
||||||
|
|
||||||
/* Agent semantic colors (legacy aliases) */
|
|
||||||
--nx-iris: var(--clr-iris);
|
|
||||||
--nx-bao: var(--clr-bao);
|
|
||||||
--nx-agent: var(--clr-agent);
|
|
||||||
--nx-review: var(--clr-review);
|
|
||||||
--nx-stale: var(--clr-stale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Glass card utility ────────────────────────────── */
|
/* ── Glass card utility ────────────────────────────── */
|
||||||
@@ -155,53 +108,3 @@
|
|||||||
/* ── 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); }
|
|
||||||
|
|||||||
@@ -0,0 +1,741 @@
|
|||||||
|
<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>
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
<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: var(--tx);
|
color: #fff;
|
||||||
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:var(--a-blue); border-color:rgba(79,124,255,.3); }
|
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
|
||||||
.badge-purple { background:rgba(181,87,246,.14); color:var(--a-purple); border-color:rgba(181,87,246,.3); }
|
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
|
||||||
.badge-amber { background:rgba(251,191,36,.13); color:var(--st-queue); border-color:rgba(251,191,36,.3); }
|
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
|
||||||
.badge-green { background:rgba(61,220,151,.13); color:var(--st-work); border-color:rgba(61,220,151,.3); }
|
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
|
||||||
.badge-cyan { background:rgba(52,214,245,.13); color:var(--st-think); border-color:rgba(52,214,245,.3); }
|
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
|
||||||
.badge-rose { background:rgba(251,113,133,.13); color:var(--st-block); border-color:rgba(251,113,133,.3); }
|
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
|
||||||
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
|
.badge-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, var(--st-work), var(--st-work));
|
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||||
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: var(--st-think);
|
color: #9fe8fb;
|
||||||
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: var(--tx);
|
color: #fff;
|
||||||
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: var(--tx);
|
color: #fff;
|
||||||
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, var(--st-work), var(--st-work));
|
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 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: var(--st-block);
|
color: #fda4b0;
|
||||||
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="var(--a-blue)"/><stop offset="1" stop-color="var(--a-purple)"/></linearGradient></defs>`
|
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 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="var(--st-work)" 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="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
|
||||||
pulses += `<circle r="3.4" fill="var(--st-work)"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
|
pulses += `<circle r="3.4" fill="#eafff6"><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="var(--a-mid)" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
|
pulses += `<circle r="2.8" fill="#c9b8ff" 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="var(--a-mid)" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
|
paths += `<path d="${d}" fill="none" stroke="#8b7cff" 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: var(--tx);
|
color: #fff;
|
||||||
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: var(--tx);
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iris-name {
|
.iris-name {
|
||||||
@@ -196,7 +196,7 @@ watch(
|
|||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg-info.error { color: var(--st-block); font-style: normal; }
|
.chat-msg-info.error { color: #fda4b0; 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: var(--tx);
|
color: #fff;
|
||||||
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: var(--tx);
|
color: #fff;
|
||||||
}
|
}
|
||||||
</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' ? 'var(--st-block)' : p === 'medium' ? 'var(--st-queue)' : 'var(--a-blue)'
|
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
|
||||||
}
|
}
|
||||||
|
|
||||||
function dotClass(s: TaskItem['status']): string {
|
function dotClass(s: TaskItem['status']): string {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
connected: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
toggleMobileNav: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
|
||||||
|
<Command :size="19" />
|
||||||
|
</button>
|
||||||
|
<div class="search">
|
||||||
|
<Search :size="16" />
|
||||||
|
<span>Search operations</span>
|
||||||
|
<kbd>⌘ K</kbd>
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<span :class="['connection', connected ? 'live' : 'preview']">
|
||||||
|
<CircleDot :size="13" />
|
||||||
|
{{ connected ? 'Live' : 'Preview data' }}
|
||||||
|
</span>
|
||||||
|
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-bottom: 1px solid var(--nx-line, #1f2330);
|
||||||
|
background: var(--nx-panel, #11141b);
|
||||||
|
}
|
||||||
|
.mobile-menu { display: none; }
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--nx-line, #1f2330);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--nx-text-dim, #6f7889);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.search kbd {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 1px 4px;
|
||||||
|
border: 1px solid #2a2f3d;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #4a5266;
|
||||||
|
}
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.connection {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.connection.live { color: #27ae60; background: rgba(39,174,96,.1); }
|
||||||
|
.connection.preview { color: #e67e22; background: rgba(230,126,34,.1); }
|
||||||
|
.ask {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--nx-accent, #7b6ef2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--nx-line, #1f2330); border-radius: 6px; background: transparent; color: var(--nx-accent, #7b6ef2); cursor: pointer; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
Activity, Bell, Bot, Boxes, Command, FileText,
|
||||||
|
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
|
||||||
|
Shield, SlidersHorizontal, Sparkles, BookOpen,
|
||||||
|
AlertTriangle, Calendar,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import { useNotificationStore } from '../../stores/notifications'
|
||||||
|
import { initials } from '../../utils/format'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activeView: string
|
||||||
|
mobileNavOpen: boolean
|
||||||
|
queuedTasks: number
|
||||||
|
incidents: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [label: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
notificationStore.startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
const ownerInitials = computed(() =>
|
||||||
|
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||||
|
)
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ label: 'Memory', icon: FileText },
|
||||||
|
{ label: 'Docs', icon: BookOpen },
|
||||||
|
{ label: 'Security', icon: Shield },
|
||||||
|
{ label: 'Projects', icon: Boxes },
|
||||||
|
{ label: 'Task Board', icon: ListTodo },
|
||||||
|
{ label: 'Incidents', icon: AlertTriangle },
|
||||||
|
{ separator: true },
|
||||||
|
{ label: 'Notifications', icon: Bell },
|
||||||
|
{ label: 'Calendar', icon: Calendar },
|
||||||
|
{ label: 'Agents', icon: Bot },
|
||||||
|
{ label: 'Models', icon: SlidersHorizontal },
|
||||||
|
{ label: 'Activity', icon: Activity },
|
||||||
|
{ label: 'Mobile Chat', icon: MessageSquareText },
|
||||||
|
]
|
||||||
|
|
||||||
|
function onNavigate(label: string) {
|
||||||
|
emit('navigate', label)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await auth.logout()
|
||||||
|
await router.replace('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside :class="['sidebar', { open: mobileNavOpen }]">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark"><Command :size="18" /></div>
|
||||||
|
<div>
|
||||||
|
<strong>NEXUS</strong>
|
||||||
|
<span>Noveria Operations</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<template v-for="item in navigation" :key="item.label ?? 'sep'">
|
||||||
|
<div v-if="item.separator" class="nav-separator"></div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:class="{ active: activeView === item.label }"
|
||||||
|
@click="onNavigate(item.label)"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" :size="17" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
|
||||||
|
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
|
||||||
|
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
|
||||||
|
<button class="owner" type="button" title="Sign out" @click="logout">
|
||||||
|
<div class="avatar">{{ ownerInitials }}</div>
|
||||||
|
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
|
||||||
|
<LogOut :size="15" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: 210px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--panel, #11141b);
|
||||||
|
border-right: 1px solid var(--line, #1f2330);
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 10px 12px;
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--accent, #7b6ef2);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.brand div strong { display: block; font-size: 10px; letter-spacing: .08em; }
|
||||||
|
.brand div span { font-size: 8px; color: var(--text-dim, #6f7889); }
|
||||||
|
.nav {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
padding: 4px 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.nav button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ea5b3;
|
||||||
|
font-size: 10.5px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.nav button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||||
|
.nav button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
|
||||||
|
.nav button i {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--accent, #7b6ef2);
|
||||||
|
color: #fff;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.nav button i.badge-red {
|
||||||
|
background: #e16e75;
|
||||||
|
}
|
||||||
|
.nav-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 6px 10px;
|
||||||
|
background: var(--nx-line, #1f2330);
|
||||||
|
}
|
||||||
|
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
|
||||||
|
.sidebar-bottom > button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #9ea5b3;
|
||||||
|
font-size: 10.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
}
|
||||||
|
.sidebar-bottom > button:hover { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||||
|
.sidebar-bottom > button.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
|
||||||
|
.owner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.owner div strong { display: block; font-size: 9px; }
|
||||||
|
.owner div span { font-size: 7.5px; color: var(--text-dim, #6f7889); text-transform: capitalize; }
|
||||||
|
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
|
||||||
|
.owner:hover > svg:last-child { opacity: 1; }
|
||||||
|
.avatar {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--accent, #7b6ef2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
|
||||||
|
.sidebar.open { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { icons } from '../../composables/icons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
route?: string
|
||||||
|
count?: string
|
||||||
|
active?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isActive = computed(() => {
|
||||||
|
if (props.active) return true
|
||||||
|
if (props.route && route.path === props.route) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
function navigate() {
|
||||||
|
if (props.route) {
|
||||||
|
router.push(props.route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:class="['nav-item', { active: isActive }]"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
<!-- Icon -->
|
||||||
|
<span class="nav-icon" v-html="icons[icon] || ''"></span>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<span class="nav-label">{{ label }}</span>
|
||||||
|
|
||||||
|
<!-- Count badge -->
|
||||||
|
<span v-if="count !== undefined" class="count">{{ count }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tx-2);
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: background .16s, color .16s;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: rgba(124,108,255,.08);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 3px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--grad);
|
||||||
|
box-shadow: var(--glow-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon :deep(svg) {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(124,108,255,.16);
|
||||||
|
color: var(--tx);
|
||||||
|
line-height: 1.4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,180 +1,139 @@
|
|||||||
<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 { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useNotificationStore } from '../../stores/notifications'
|
import { useAgentStore } from '../../stores/agents'
|
||||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
import { railNav, railFooterNav, svg } from '../../composables/icons'
|
import { navigation, icons } from '../../composables/icons'
|
||||||
import { initials } from '../../utils/format'
|
import type { NavGroupDef } from '../../composables/icons'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
mobileOpen?: boolean
|
mobileOpen?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 notificationStore = useNotificationStore()
|
const agentStore = useAgentStore()
|
||||||
const liveSync = useLiveSyncStore()
|
const taskStore = useTaskStore()
|
||||||
|
|
||||||
const ownerInitials = computed(() =>
|
const ownerInitials = computed(() =>
|
||||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||||
)
|
)
|
||||||
|
|
||||||
function isActive(itemRoute?: string): boolean {
|
function logout() {
|
||||||
if (!itemRoute) return false
|
auth.logout()
|
||||||
if (route.path === itemRoute) return true
|
router.replace('/login')
|
||||||
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
|
|
||||||
return route.path.startsWith(itemRoute + '/')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(itemRoute?: string) {
|
/**
|
||||||
if (!itemRoute) return
|
* Dynamische Nav-Item-Counts aus den Stores.
|
||||||
emit('close')
|
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
|
||||||
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
|
||||||
|
|
||||||
async function logout() {
|
switch (item.label) {
|
||||||
await auth.logout()
|
case 'Agenten':
|
||||||
await router.replace('/login')
|
case 'Hosts · OpenClaw':
|
||||||
}
|
dynamicCount = String(agentStore.agentList.length)
|
||||||
|
break
|
||||||
|
case 'Task Board':
|
||||||
|
dynamicCount = String(taskStore.taskList.length)
|
||||||
|
break
|
||||||
|
case 'Kosten & Tokens':
|
||||||
|
dynamicCount = agentStore.todayCost
|
||||||
|
break
|
||||||
|
case 'Docs & .md':
|
||||||
|
dynamicCount = '0'
|
||||||
|
break
|
||||||
|
case 'Incidents':
|
||||||
|
dynamicCount = '0'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
const statusLabel = computed(() => {
|
return {
|
||||||
if (liveSync.connected) return 'Live'
|
...item,
|
||||||
if (liveSync.connecting) return 'Verbinde…'
|
count: dynamicCount ?? item.count,
|
||||||
return 'Polling'
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside :class="['rail', { open: mobileOpen }]">
|
<aside :class="['sidebar', { open: mobileOpen }]">
|
||||||
|
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<button class="rail-brand" @click="navigate('/dashboard')">
|
<div class="side-top">
|
||||||
<span class="brand-mark" v-html="svg('command')"></span>
|
<div class="brand-mark" v-html="icons.command || ''"></div>
|
||||||
<span class="rail-label brand-label">NEXUS</span>
|
<div>
|
||||||
</button>
|
<div class="brand-name">NEXUS</div>
|
||||||
|
<div class="brand-sub">Mission Control</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="rail-nav v2-scroll">
|
<nav class="nav">
|
||||||
<button
|
<NavGroup
|
||||||
v-for="item in railNav"
|
v-for="(group, idx) in dynamicNavigation"
|
||||||
:key="item.route"
|
:key="idx"
|
||||||
:class="['rail-item', { active: isActive(item.route) }]"
|
:label="group.group"
|
||||||
:title="item.label"
|
:items="group.items"
|
||||||
@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="rail-foot">
|
<div class="side-foot">
|
||||||
<div class="rail-item static" :title="statusLabel">
|
<div class="avatar">{{ ownerInitials }}</div>
|
||||||
<span class="rail-icon">
|
<div class="owner-info">
|
||||||
<span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
|
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
|
||||||
</span>
|
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
|
||||||
<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>
|
||||||
.rail {
|
.sidebar {
|
||||||
position: absolute;
|
width: 248px;
|
||||||
inset: 0 auto 0 0;
|
flex: 0 0 248px;
|
||||||
width: 68px;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
overflow: hidden;
|
padding: 0;
|
||||||
transition: width .18s ease, box-shadow .18s ease;
|
position: relative;
|
||||||
z-index: 100;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail:hover {
|
.side-top {
|
||||||
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: 13px;
|
gap: 11px;
|
||||||
padding: 15px;
|
padding: 18px 18px 16px;
|
||||||
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) {
|
||||||
@@ -183,154 +142,108 @@ const statusLabel = computed(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-label {
|
.brand-name {
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
letter-spacing: .14em;
|
letter-spacing: .14em;
|
||||||
color: var(--tx);
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Nav ── */
|
.brand-sub {
|
||||||
.rail-nav {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-item {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 13px;
|
|
||||||
height: 42px;
|
|
||||||
padding: 0 13px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border: none;
|
|
||||||
border-radius: 11px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--tx-2);
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-weight: 500;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-item:not(.static):hover {
|
|
||||||
background: rgba(124, 108, 255, .08);
|
|
||||||
color: var(--tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-item.active {
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(90deg, rgba(124, 108, 255, .22), rgba(124, 108, 255, .04));
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, .25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-item.static {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-icon {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
flex: 0 0 18px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
opacity: .9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-icon :deep(svg) {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-item .rail-label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ungelesen-Punkt am Icon (collapsed sichtbar) */
|
|
||||||
.rail-dot {
|
|
||||||
position: absolute;
|
|
||||||
left: 24px;
|
|
||||||
top: 9px;
|
|
||||||
width: 7px;
|
|
||||||
height: 7px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--st-block);
|
|
||||||
box-shadow: 0 0 8px rgba(251, 113, 133, .8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail-count {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
font-weight: 600;
|
color: var(--tx-3);
|
||||||
padding: 1px 8px;
|
letter-spacing: .05em;
|
||||||
border-radius: 20px;
|
margin-top: 3px;
|
||||||
background: rgba(251, 113, 133, .16);
|
|
||||||
border: 1px solid rgba(251, 113, 133, .35);
|
|
||||||
color: var(--st-block);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Footer ── */
|
.nav {
|
||||||
.rail-foot {
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 6px 12px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 3px;
|
gap: 2px;
|
||||||
padding: 6px 12px 10px;
|
}
|
||||||
|
|
||||||
|
.side-foot {
|
||||||
|
padding: 12px;
|
||||||
border-top: 1px solid var(--line);
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 13px;
|
gap: 10px;
|
||||||
padding: 7px 5px 2px;
|
cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-foot:hover {
|
||||||
|
background: rgba(124,108,255,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 100vh;
|
||||||
|
width: 280px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 12px;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tx-2);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close:hover {
|
||||||
|
background: rgba(124,108,255,.1);
|
||||||
|
color: var(--tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close :deep(svg) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
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: 12px;
|
font-size: 13px;
|
||||||
color: var(--tx);
|
color: var(--tx);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-label {
|
.owner-info {
|
||||||
flex: 1;
|
min-width: 0;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-name {
|
.owner-name {
|
||||||
@@ -345,41 +258,7 @@ const statusLabel = computed(() => {
|
|||||||
.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>
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<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>
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/**
|
|
||||||
* ActivityTimeline — Wiederverwendbare Aktivitäts-Feed-Komponente
|
|
||||||
*
|
|
||||||
* Standardisierte Timeline-Darstellung für Task-Aktivität,
|
|
||||||
* Agent-Aktivität und allgemeine Ereignis-Feeds.
|
|
||||||
* Verwendet ausschließlich nexus-tokens.css Variablen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ActivityEntry {
|
|
||||||
id?: string
|
|
||||||
message: string
|
|
||||||
timestamp?: string
|
|
||||||
type?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
|
||||||
entries: ActivityEntry[]
|
|
||||||
loading?: boolean
|
|
||||||
emptyLabel?: string
|
|
||||||
}>(), {
|
|
||||||
emptyLabel: 'Noch keine Aktivität',
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDate(date?: string | null): string {
|
|
||||||
if (!date) return ''
|
|
||||||
return new Date(date).toLocaleString('de-DE', {
|
|
||||||
dateStyle: 'medium',
|
|
||||||
timeStyle: 'short',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="activity-timeline">
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="loading" class="activity-empty">Lade…</div>
|
|
||||||
|
|
||||||
<!-- Empty -->
|
|
||||||
<div v-else-if="!entries.length" class="activity-empty">{{ emptyLabel }}</div>
|
|
||||||
|
|
||||||
<!-- Entries -->
|
|
||||||
<article v-for="(entry, index) in entries" :key="entry.id ?? index" class="activity-item">
|
|
||||||
<div class="activity-dot"></div>
|
|
||||||
<div class="activity-body">
|
|
||||||
<div class="activity-message">{{ entry.message }}</div>
|
|
||||||
<div v-if="entry.timestamp" class="activity-time">
|
|
||||||
{{ formatDate(entry.timestamp) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.activity-timeline {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-empty {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--tx-3);
|
|
||||||
font-style: italic;
|
|
||||||
padding: 8px;
|
|
||||||
text-align: center;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 10px minmax(0, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
margin-top: 5px;
|
|
||||||
background: var(--grad);
|
|
||||||
box-shadow: 0 0 0 4px rgba(124, 108, 255, .12);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-body {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-message {
|
|
||||||
color: var(--tx);
|
|
||||||
font-size: 12.5px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-time {
|
|
||||||
color: var(--tx-3);
|
|
||||||
font-size: 10.5px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
<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'
|
||||||
@@ -15,25 +8,10 @@ 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: {
|
||||||
@@ -57,78 +35,3 @@ 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>
|
|
||||||
|
|||||||
@@ -1,54 +1,16 @@
|
|||||||
<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 = withDefaults(defineProps<Props>(), {
|
const props = defineProps<Props>()
|
||||||
variant: 'glass',
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||||
: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>
|
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<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: 'var(--st-work)',
|
color: '#22c55e',
|
||||||
bg: 'rgba(61, 220, 151, 0.10)',
|
bg: 'rgba(34, 197, 94, 0.10)',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
icon: XCircle,
|
icon: XCircle,
|
||||||
color: 'var(--st-block)',
|
color: '#ef4444',
|
||||||
bg: 'rgba(251, 113, 133, 0.10)',
|
bg: 'rgba(239, 68, 68, 0.10)',
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
icon: Info,
|
icon: Info,
|
||||||
color: 'var(--a-blue)',
|
color: '#3b82f6',
|
||||||
bg: 'rgba(79, 124, 255, 0.10)',
|
bg: 'rgba(59, 130, 246, 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: var(--tx);
|
color: #e8eaf0;
|
||||||
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: var(--tx-3);
|
color: #6b7385;
|
||||||
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: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition animations */
|
/* Transition animations */
|
||||||
|
|||||||
@@ -4,35 +4,25 @@ 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-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',
|
'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',
|
||||||
{
|
{
|
||||||
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: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
destructive:
|
||||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
outline:
|
||||||
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
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: {
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
export { default as Badge } from './Badge.vue'
|
|
||||||
export { default as Card } from './Card.vue'
|
|
||||||
export { default as StatusDot } from './StatusDot.vue'
|
|
||||||
export { default as PageHeading } from './PageHeading.vue'
|
|
||||||
export { default as EmptyState } from './EmptyState.vue'
|
|
||||||
export { default as ActivityTimeline } from './ActivityTimeline.vue'
|
|
||||||
export { default as SectionHeader } from './SectionHeader.vue'
|
|
||||||
export { default as StatusPill } from './StatusPill.vue'
|
|
||||||
export { default as SkeletonLoader } from './SkeletonLoader.vue'
|
|
||||||
export { default as Input } from './Input.vue'
|
|
||||||
export { default as Textarea } from './Textarea.vue'
|
|
||||||
export { default as Select } from './Select.vue'
|
|
||||||
export { default as Dialog } from './Dialog.vue'
|
|
||||||
export { default as ToastContainer } from './ToastContainer.vue'
|
|
||||||
export { Button } from './button'
|
|
||||||
@@ -25,10 +25,6 @@ export const icons: Record<string, string> = {
|
|||||||
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
|
arrow: `<path d="M5 12h14M13 6l6 6-6 6"/>`,
|
||||||
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"/>`,
|
|
||||||
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"/>`,
|
||||||
@@ -54,22 +50,40 @@ export interface NavGroupDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rail-Navigation — EINE Quelle für alle Seiten (Dashboard + Rest).
|
* Navigation structure matching NEXUS.nav from agents.js
|
||||||
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
|
|
||||||
* unten im Fußbereich (railFooterNav).
|
|
||||||
*/
|
*/
|
||||||
export const railNav: NavItemDef[] = [
|
export const navigation: NavGroupDef[] = [
|
||||||
{ icon: 'grid', label: 'Dashboard', route: '/dashboard' },
|
{
|
||||||
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
group: 'Operations',
|
||||||
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
items: [
|
||||||
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
|
||||||
{ icon: 'doc', label: 'Docs', route: '/docs' },
|
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
||||||
{ icon: 'calendar', label: 'Kalender', route: '/calendar' },
|
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
||||||
{ icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
|
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
|
||||||
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
],
|
||||||
{ icon: 'shield', label: 'Security', route: '/security' },
|
},
|
||||||
]
|
{
|
||||||
|
group: 'Knowledge',
|
||||||
export const railFooterNav: NavItemDef[] = [
|
items: [
|
||||||
{ icon: 'gear', label: 'Einstellungen', route: '/settings' },
|
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
||||||
|
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
|
||||||
|
{ icon: 'search', label: 'Research', route: '/research' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Infrastructure',
|
||||||
|
items: [
|
||||||
|
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
|
||||||
|
{ icon: 'model', label: 'Modelle', route: '/models' },
|
||||||
|
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: 'Governance',
|
||||||
|
items: [
|
||||||
|
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
|
||||||
|
{ icon: 'shield', label: 'Security', route: '/security' },
|
||||||
|
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
||||||
|
],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
/**
|
|
||||||
* useFormatDate — Einheitliche Datums-/Zeitformatierung für Nexus V2
|
|
||||||
*
|
|
||||||
* Kapselt die mehrfach wiederholten formatDate-, relativeTime- und
|
|
||||||
* toDateInputValue-Helper, die bisher in TaskBoardView, BoardCard,
|
|
||||||
* mit anderen Views dupliziert waren.
|
|
||||||
*
|
|
||||||
* Alle Ausgaben orientieren sich an de-DE Locale.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Datum als lesbaren String (de-DE, kurzes/medium-DateStyle).
|
|
||||||
*/
|
|
||||||
export function formatDate(date?: string | null, withTime = false): string {
|
|
||||||
if (!date) return '—'
|
|
||||||
return new Date(date).toLocaleString('de-DE', withTime
|
|
||||||
? { dateStyle: 'medium', timeStyle: 'short' }
|
|
||||||
: { dateStyle: 'medium' })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ISO-Datumsstring in YYYY-MM-DD für <input type="date">.
|
|
||||||
*/
|
|
||||||
export function toDateInputValue(date?: string | null): string {
|
|
||||||
if (!date) return ''
|
|
||||||
return new Date(date).toISOString().slice(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Relative Zeit ("gerade eben", "vor 5 min", "vor 3 h", "vor 2 d").
|
|
||||||
* Optionaler Fallback, wenn date fehlt.
|
|
||||||
*/
|
|
||||||
export function relativeTime(date?: string | null, fallback = 'keine Aktivität'): string {
|
|
||||||
if (!date) return fallback
|
|
||||||
const diffMs = Date.now() - new Date(date).getTime()
|
|
||||||
const mins = Math.max(0, Math.round(diffMs / 60000))
|
|
||||||
if (mins < 1) return 'gerade eben'
|
|
||||||
if (mins < 60) return `vor ${mins} min`
|
|
||||||
const hours = Math.round(mins / 60)
|
|
||||||
if (hours < 24) return `vor ${hours} h`
|
|
||||||
const days = Math.round(hours / 24)
|
|
||||||
return `vor ${days} d`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minuten seit einem Datum (oder Infinity falls kein Datum).
|
|
||||||
*/
|
|
||||||
export function minutesSince(dateStr?: string | null): number {
|
|
||||||
if (!dateStr) return Infinity
|
|
||||||
return (Date.now() - new Date(dateStr).getTime()) / 60000
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stunden seit einem Datum (gerundet).
|
|
||||||
*/
|
|
||||||
export function hoursSince(dateStr: string): number {
|
|
||||||
const now = Date.now()
|
|
||||||
const then = new Date(dateStr).getTime()
|
|
||||||
return Math.round((now - then) / 3600000)
|
|
||||||
}
|
|
||||||
@@ -1,79 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* NexusLayout — gemeinsame Shell für ALLE Seiten
|
* NexusLayout — V2 Dashboard Shell
|
||||||
*
|
* Flex row, 100vh, overflow hidden.
|
||||||
* Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
|
* Sidebar (248px) + Main (flex:1, flex-column)
|
||||||
* Content bekommt die volle restliche Fläche:
|
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
|
||||||
* - 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 { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useDashboardStore } from '../stores/dashboard'
|
||||||
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 { svg } from '../composables/icons'
|
import Topbar from '../components/layout/Topbar.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const dashboardStore = useDashboardStore()
|
||||||
const auth = useAuthStore()
|
|
||||||
const liveSync = useLiveSyncStore()
|
|
||||||
const notificationStore = useNotificationStore()
|
|
||||||
|
|
||||||
const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
|
/* ── Mobile Sidebar State ───────────────────────── */
|
||||||
|
|
||||||
/* ── 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"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="rail-slot">
|
<!-- Mobile Backdrop -->
|
||||||
<Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
|
<div
|
||||||
</div>
|
v-if="mobileMenuOpen"
|
||||||
|
class="mobile-backdrop"
|
||||||
<!-- Mobile: Hamburger + Backdrop -->
|
@click="closeMobileMenu"
|
||||||
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
|
></div>
|
||||||
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
|
|
||||||
|
|
||||||
<main class="nexus-main">
|
<main class="nexus-main">
|
||||||
<div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
|
<Topbar
|
||||||
|
:connected="dashboardStore.isGatewayConnected"
|
||||||
|
:status-label="dashboardStore.irisStatusLabel"
|
||||||
|
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
|
||||||
|
/>
|
||||||
|
<div class="nexus-content">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -89,15 +59,6 @@ onUnmounted(() => {
|
|||||||
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;
|
||||||
@@ -109,62 +70,19 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.nexus-content {
|
.nexus-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-content.full-bleed {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nexus-content.full-bleed > :deep(*) {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
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;
|
||||||
@@ -172,9 +90,5 @@ onUnmounted(() => {
|
|||||||
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>
|
||||||
|
|||||||
+19
-15
@@ -19,27 +19,31 @@ 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' },
|
||||||
|
|
||||||
// Eine Shell für alle Seiten (Rail-Navigation, app-weiter Live-Sync)
|
// V2 Dashboard (neues NexusLayout + FlowBoard)
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/dashboard',
|
||||||
component: NexusLayout,
|
component: NexusLayout,
|
||||||
children: [
|
children: [
|
||||||
{ path: 'dashboard', name: 'Dashboard', component: FlowBoard, meta: { fullBleed: true } },
|
{ path: '', name: 'Dashboard', component: FlowBoard },
|
||||||
{ 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' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { openDashboardLiveStream } from '../services/live'
|
||||||
|
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
||||||
|
import type { NotificationItem } from './notifications'
|
||||||
|
import type { TaskItem } from '../components/dashboard/v2/types'
|
||||||
|
import { useTaskStore } from './tasks'
|
||||||
|
import { useNotificationStore } from './notifications'
|
||||||
|
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
||||||
|
|
||||||
|
interface NotificationSnapshotDto {
|
||||||
|
notifications: NotificationItem[]
|
||||||
|
unreadCount: number
|
||||||
|
forUser: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardLiveSnapshotDto {
|
||||||
|
board: BoardGroup
|
||||||
|
notifications: NotificationSnapshotDto
|
||||||
|
cursor: LiveCursorDto
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBoardGroup(value: unknown): value is BoardGroup {
|
||||||
|
const v = value as BoardGroup
|
||||||
|
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
||||||
|
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
agent: t.assignedTo ?? '—',
|
||||||
|
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
||||||
|
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
||||||
|
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
||||||
|
detail: t.detail,
|
||||||
|
source: t.source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLiveSyncStore = defineStore('liveSync', {
|
||||||
|
state: () => ({
|
||||||
|
connected: false,
|
||||||
|
connecting: false,
|
||||||
|
lastEventAt: null as string | null,
|
||||||
|
lastHeartbeatAt: null as string | null,
|
||||||
|
error: null as string | null,
|
||||||
|
controller: null as AbortController | null,
|
||||||
|
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
mode: 'polling' as 'polling' | 'live',
|
||||||
|
lastSequence: 0,
|
||||||
|
reconnectAttempts: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
liveIndicatorLabel: (state) => {
|
||||||
|
if (state.connecting) return 'Verbinde…'
|
||||||
|
if (state.connected) return `Live · #${state.lastSequence}`
|
||||||
|
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
||||||
|
},
|
||||||
|
connectionHealth: (state) => {
|
||||||
|
if (state.connected) return 'healthy'
|
||||||
|
if (state.connecting) return 'connecting'
|
||||||
|
return 'degraded'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async connect(forUser = 'bao') {
|
||||||
|
if (this.connecting || this.connected) return
|
||||||
|
this.connecting = true
|
||||||
|
this.error = null
|
||||||
|
this.controller = new AbortController()
|
||||||
|
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await openDashboardLiveStream((event, data) => {
|
||||||
|
this.lastEventAt = new Date().toISOString()
|
||||||
|
|
||||||
|
if (event === 'heartbeat') {
|
||||||
|
const cursor = data as LiveCursorDto
|
||||||
|
this.lastHeartbeatAt = cursor.timestamp
|
||||||
|
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event === 'snapshot') {
|
||||||
|
const snapshot = data as DashboardLiveSnapshotDto
|
||||||
|
taskStore.board = snapshot.board
|
||||||
|
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
||||||
|
notificationStore.notifications = snapshot.notifications.notifications
|
||||||
|
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
||||||
|
this.lastSequence = snapshot.cursor.sequence
|
||||||
|
this.connected = true
|
||||||
|
this.mode = 'live'
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
taskStore.stopBoardPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventDto = data as DashboardLiveEventDto
|
||||||
|
this.applyEnvelope(eventDto.envelope, forUser)
|
||||||
|
this.lastSequence = eventDto.cursor.sequence
|
||||||
|
this.connected = true
|
||||||
|
this.mode = 'live'
|
||||||
|
this.reconnectAttempts = 0
|
||||||
|
taskStore.stopBoardPolling()
|
||||||
|
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||||
|
|
||||||
|
await stream.closed
|
||||||
|
} catch (error) {
|
||||||
|
if (this.controller?.signal.aborted) return
|
||||||
|
console.warn('[liveSync] stream failed, falling back to polling', error)
|
||||||
|
this.error = 'Live updates unavailable'
|
||||||
|
this.connected = false
|
||||||
|
this.mode = 'polling'
|
||||||
|
taskStore.startBoardPolling()
|
||||||
|
this.scheduleReconnect(forUser)
|
||||||
|
} finally {
|
||||||
|
this.connecting = false
|
||||||
|
if (!this.controller?.signal.aborted && !this.connected) {
|
||||||
|
this.mode = 'polling'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
||||||
|
const taskStore = useTaskStore()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
|
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
||||||
|
taskStore.board = envelope.payload
|
||||||
|
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.type === 'notifications.snapshot') {
|
||||||
|
const snapshot = envelope.payload as NotificationSnapshotDto
|
||||||
|
if (snapshot.forUser !== forUser) return
|
||||||
|
notificationStore.notifications = snapshot.notifications
|
||||||
|
notificationStore.unreadCount = snapshot.unreadCount
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.controller?.abort()
|
||||||
|
this.controller = null
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.mode = 'polling'
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer)
|
||||||
|
this.reconnectTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
scheduleReconnect(forUser = 'bao') {
|
||||||
|
if (this.reconnectTimer) return
|
||||||
|
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||||
|
this.reconnectAttempts += 1
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = null
|
||||||
|
this.connect(forUser)
|
||||||
|
}, delay)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -50,11 +50,9 @@ 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: {
|
||||||
@@ -75,9 +73,7 @@ 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()
|
||||||
@@ -117,51 +113,19 @@ 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) {
|
if (this.controller?.signal.aborted) return
|
||||||
console.warn('[liveSync] stream failed', error)
|
console.warn('[liveSync] stream failed, falling back to polling', 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
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -184,7 +148,6 @@ 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
|
||||||
@@ -198,9 +161,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
|||||||
|
|
||||||
scheduleReconnect(forUser = 'bao') {
|
scheduleReconnect(forUser = 'bao') {
|
||||||
if (this.reconnectTimer) return
|
if (this.reconnectTimer) return
|
||||||
// Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
|
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||||
// 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
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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
|
||||||
@@ -216,29 +215,6 @@ 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 {
|
||||||
@@ -321,18 +297,6 @@ 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
|
||||||
|
|||||||
@@ -143,10 +143,10 @@ function formatModifiedAt(dateStr: string): string {
|
|||||||
|
|
||||||
const statusColor = (status: string): string => {
|
const statusColor = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Online': return 'var(--st-work)'
|
case 'Online': return '#51d49a'
|
||||||
case 'Degraded': return 'var(--st-queue)'
|
case 'Degraded': return '#e5b05e'
|
||||||
case 'Offline': return 'var(--st-block)'
|
case 'Offline': return '#e16e75'
|
||||||
default: return 'var(--tx-3)'
|
default: return '#7e8799'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,15 +612,15 @@ onUnmounted(() => {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.back-link:hover {
|
.back-link:hover {
|
||||||
border-color: var(--line-3);
|
border-color: #443d7c;
|
||||||
color: var(--tx);
|
color: #d8dbe3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-message {
|
.status-message {
|
||||||
@@ -629,11 +629,11 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.status-message.error {
|
.status-message.error {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.status-message.compact {
|
.status-message.compact {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
@@ -660,15 +660,15 @@ onUnmounted(() => {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(139,124,246,.1);
|
background: rgba(139,124,246,.1);
|
||||||
color: var(--a-mid);
|
color: #8b7cf6;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.agent-avatar.iris { background: rgba(139,124,246,.15); color: var(--a-mid); }
|
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; }
|
||||||
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: var(--a-blue); }
|
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; }
|
||||||
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: var(--a-blue); }
|
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; }
|
||||||
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: var(--st-queue); }
|
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; }
|
||||||
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: var(--a-purple); }
|
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; }
|
||||||
.agent-avatar.executor { background: rgba(77,246,212,.15); color: var(--st-think); }
|
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; }
|
||||||
|
|
||||||
.agent-header-info {
|
.agent-header-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -678,7 +678,7 @@ onUnmounted(() => {
|
|||||||
font-size: 8.5px;
|
font-size: 8.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .12em;
|
letter-spacing: .12em;
|
||||||
color: var(--a-mid);
|
color: var(--accent, #7b6ef2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
@@ -686,7 +686,7 @@ onUnmounted(() => {
|
|||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.agent-status-row {
|
.agent-status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -702,18 +702,18 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.status-label {
|
.status-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
.status-label.muted { color: var(--tx-3); }
|
.status-label.muted { color: #6b7385; }
|
||||||
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
.status-label.mono { font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
||||||
.status-sep { color: var(--line-2); font-size: 11px; }
|
.status-sep { color: #3d4152; font-size: 11px; }
|
||||||
|
|
||||||
.thinking-section {
|
.thinking-section {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -731,11 +731,11 @@ onUnmounted(() => {
|
|||||||
font-size: 8.5px;
|
font-size: 8.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .12em;
|
letter-spacing: .12em;
|
||||||
color: var(--a-mid);
|
color: var(--accent, #7b6ef2);
|
||||||
}
|
}
|
||||||
.section-head h2 {
|
.section-head h2 {
|
||||||
margin: 2px 0 0;
|
margin: 2px 0 0;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -744,7 +744,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.section-note {
|
.section-note {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
color: var(--tx-3);
|
color: #6f788b;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
@@ -753,10 +753,10 @@ onUnmounted(() => {
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--tx-3);
|
background: #6b7385;
|
||||||
}
|
}
|
||||||
.live-dot.on {
|
.live-dot.on {
|
||||||
background: var(--st-work);
|
background: #51d49a;
|
||||||
}
|
}
|
||||||
.icon-button {
|
.icon-button {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
@@ -764,7 +764,7 @@ onUnmounted(() => {
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: rgba(255,255,255,.03);
|
background: rgba(255,255,255,.03);
|
||||||
color: var(--tx-2);
|
color: #9ba3b5;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -790,13 +790,13 @@ onUnmounted(() => {
|
|||||||
.summary-card small {
|
.summary-card small {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 7px;
|
margin-top: 7px;
|
||||||
color: var(--tx-3);
|
color: #6f788b;
|
||||||
font-size: 9.5px;
|
font-size: 9.5px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.summary-row span {
|
.summary-row span {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--tx-3);
|
color: #6f788b;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -804,7 +804,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.summary-row p {
|
.summary-row p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--tx-2);
|
color: #cbd0dc;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -821,20 +821,20 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
color: var(--tx-3);
|
color: #6f788b;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.type-pill {
|
.type-pill {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
border: 1px solid rgba(123,110,242,.24);
|
border: 1px solid rgba(123,110,242,.24);
|
||||||
color: var(--a-mid);
|
color: #aaa1ff;
|
||||||
background: rgba(123,110,242,.08);
|
background: rgba(123,110,242,.08);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
.thinking-item p {
|
.thinking-item p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--tx-2);
|
color: #cbd0dc;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const fallbackAgents: AgentCard[] = [
|
|||||||
role: 'Chief of Staff',
|
role: 'Chief of Staff',
|
||||||
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
||||||
tags: ['Orchestration', 'Delegation', 'Approval'],
|
tags: ['Orchestration', 'Delegation', 'Approval'],
|
||||||
color: '#7c6cff',
|
color: '#8b7cf6',
|
||||||
icon: 'bot',
|
icon: 'bot',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -50,7 +50,7 @@ const fallbackAgents: AgentCard[] = [
|
|||||||
role: 'Lead Developer',
|
role: 'Lead Developer',
|
||||||
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
||||||
tags: ['Coding', 'Development', 'Builds'],
|
tags: ['Coding', 'Development', 'Builds'],
|
||||||
color: '#4f7cff',
|
color: '#4d8cf6',
|
||||||
icon: 'code',
|
icon: 'code',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,7 +59,7 @@ const fallbackAgents: AgentCard[] = [
|
|||||||
role: 'Infrastructure Engineer',
|
role: 'Infrastructure Engineer',
|
||||||
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
|
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
|
||||||
tags: ['Infrastructure', 'Deployment', 'Docker'],
|
tags: ['Infrastructure', 'Deployment', 'Docker'],
|
||||||
color: '#4f7cff',
|
color: '#4da8f6',
|
||||||
icon: 'server',
|
icon: 'server',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,7 +68,7 @@ const fallbackAgents: AgentCard[] = [
|
|||||||
role: 'Code QA',
|
role: 'Code QA',
|
||||||
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
||||||
tags: ['QA', 'Security', 'Code Review'],
|
tags: ['QA', 'Security', 'Code Review'],
|
||||||
color: '#fbbf24',
|
color: '#f6a84d',
|
||||||
icon: 'shield',
|
icon: 'shield',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -77,7 +77,7 @@ const fallbackAgents: AgentCard[] = [
|
|||||||
role: 'Research Analyst',
|
role: 'Research Analyst',
|
||||||
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
||||||
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
||||||
color: '#b557f6',
|
color: '#8b4df6',
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,7 +86,7 @@ const fallbackAgents: AgentCard[] = [
|
|||||||
role: 'Host Executor',
|
role: 'Host Executor',
|
||||||
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
||||||
tags: ['Execution', 'Docker', 'VPS'],
|
tags: ['Execution', 'Docker', 'VPS'],
|
||||||
color: '#34d6f5',
|
color: '#4df6d4',
|
||||||
icon: 'terminal',
|
icon: 'terminal',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -161,7 +161,7 @@ function enrichAgent(item: any): AgentCard {
|
|||||||
role: item.role || fallback?.role || 'Agent',
|
role: item.role || fallback?.role || 'Agent',
|
||||||
description: item.description || fallback?.description || 'OpenClaw agent',
|
description: item.description || fallback?.description || 'OpenClaw agent',
|
||||||
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
|
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
|
||||||
color: fallback?.color ?? '#6f6aa0',
|
color: fallback?.color ?? '#7e8799',
|
||||||
icon: fallback?.icon ?? 'bot',
|
icon: fallback?.icon ?? 'bot',
|
||||||
model: item.model,
|
model: item.model,
|
||||||
statusLabel: item.statusLabel,
|
statusLabel: item.statusLabel,
|
||||||
@@ -327,19 +327,19 @@ onMounted(loadMissionControl)
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: rgba(139, 124, 246, 0.1);
|
background: rgba(139, 124, 246, 0.1);
|
||||||
color: var(--a-mid);
|
color: #8b7cf6;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.header-text h1 {
|
.header-text h1 {
|
||||||
margin: 0 0 2px;
|
margin: 0 0 2px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.header-subtitle {
|
.header-subtitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.gateway-chip {
|
.gateway-chip {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -349,24 +349,24 @@ onMounted(loadMissionControl)
|
|||||||
padding: 6px 9px;
|
padding: 6px 9px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
color: var(--tx-2);
|
color: #9ba3b5;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.gateway-chip.ok {
|
.gateway-chip.ok {
|
||||||
color: var(--st-work);
|
color: #51d49a;
|
||||||
border-color: rgba(81, 212, 154, .25);
|
border-color: rgba(81, 212, 154, .25);
|
||||||
}
|
}
|
||||||
.gateway-chip.warn {
|
.gateway-chip.warn {
|
||||||
color: var(--st-queue);
|
color: #e5b05e;
|
||||||
border-color: rgba(229, 176, 94, .28);
|
border-color: rgba(229, 176, 94, .28);
|
||||||
}
|
}
|
||||||
.gateway-chip.error {
|
.gateway-chip.error {
|
||||||
color: var(--st-block);
|
color: #f29b9b;
|
||||||
border-color: rgba(242, 155, 155, .3);
|
border-color: rgba(242, 155, 155, .3);
|
||||||
}
|
}
|
||||||
.load-error {
|
.load-error {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
color: var(--st-queue);
|
color: #e5b05e;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.gateway-warning,
|
.gateway-warning,
|
||||||
@@ -376,19 +376,19 @@ onMounted(loadMissionControl)
|
|||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
border: 1px solid rgba(229, 176, 94, .24);
|
border: 1px solid rgba(229, 176, 94, .24);
|
||||||
background: rgba(229, 176, 94, .08);
|
background: rgba(229, 176, 94, .08);
|
||||||
color: var(--st-queue);
|
color: #f1d7aa;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border-color: var(--line);
|
border-color: var(--line);
|
||||||
background: rgba(255,255,255,.03);
|
background: rgba(255,255,255,.03);
|
||||||
color: var(--tx-2);
|
color: #aab2c3;
|
||||||
}
|
}
|
||||||
.empty-state h3 {
|
.empty-state h3 {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -451,7 +451,7 @@ onMounted(loadMissionControl)
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,19 +464,19 @@ onMounted(loadMissionControl)
|
|||||||
margin: 0 0 1px;
|
margin: 0 0 1px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-role {
|
.card-role {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -486,7 +486,7 @@ onMounted(loadMissionControl)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
color: var(--tx-2);
|
color: #8a92a5;
|
||||||
font-size: 9.5px;
|
font-size: 9.5px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -494,19 +494,19 @@ onMounted(loadMissionControl)
|
|||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--tx-3);
|
background: #6b7385;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.runtime-dot.on {
|
.runtime-dot.on {
|
||||||
background: var(--st-work);
|
background: #51d49a;
|
||||||
}
|
}
|
||||||
.runtime-dot.connected { background: var(--st-work); }
|
.runtime-dot.connected { background: #51d49a; }
|
||||||
.runtime-dot.thinking { background: var(--a-blue); }
|
.runtime-dot.thinking { background: #79aaff; }
|
||||||
.runtime-dot.blocked { background: var(--st-block); }
|
.runtime-dot.blocked { background: #f87171; }
|
||||||
.runtime-dot.stale { background: var(--st-queue); }
|
.runtime-dot.stale { background: #f59e0b; }
|
||||||
.runtime-dot.error { background: var(--st-block); }
|
.runtime-dot.error { background: #f29b9b; }
|
||||||
.runtime-dot.unsupported { background: var(--st-queue); }
|
.runtime-dot.unsupported { background: #e5b05e; }
|
||||||
.runtime-dot.ready { background: var(--tx-3); }
|
.runtime-dot.ready { background: #6b7385; }
|
||||||
.runtime-model {
|
.runtime-model {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
max-width: 46%;
|
max-width: 46%;
|
||||||
@@ -514,12 +514,12 @@ onMounted(loadMissionControl)
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
color: var(--tx-3);
|
color: #6f788b;
|
||||||
}
|
}
|
||||||
.runtime-detail {
|
.runtime-detail {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
@@ -561,7 +561,7 @@ onMounted(loadMissionControl)
|
|||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.calendar-panel {
|
.calendar-panel {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ onMounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.calendar-status {
|
.calendar-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -243,11 +243,11 @@ onMounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.calendar-status.error {
|
.calendar-status.error {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Upcoming jobs */
|
/* Upcoming jobs */
|
||||||
@@ -268,25 +268,25 @@ onMounted(() => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
background: rgba(139,124,246,.1);
|
background: rgba(139,124,246,.1);
|
||||||
}
|
}
|
||||||
.upcoming-item-info strong {
|
.upcoming-item-info strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.upcoming-item-schedule {
|
.upcoming-item-schedule {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.upcoming-item-next {
|
.upcoming-item-next {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,19 +310,19 @@ onMounted(() => {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.job-item-icon.status-completed {
|
.job-item-icon.status-completed {
|
||||||
color: var(--st-work);
|
color: #27ae60;
|
||||||
background: rgba(39, 174, 96, 0.12);
|
background: rgba(39, 174, 96, 0.12);
|
||||||
}
|
}
|
||||||
.job-item-icon.status-running {
|
.job-item-icon.status-running {
|
||||||
color: var(--a-blue);
|
color: #3498db;
|
||||||
background: rgba(52, 152, 219, 0.12);
|
background: rgba(52, 152, 219, 0.12);
|
||||||
}
|
}
|
||||||
.job-item-icon.status-failed {
|
.job-item-icon.status-failed {
|
||||||
color: var(--st-block);
|
color: #e74c3c;
|
||||||
background: rgba(231, 76, 60, 0.12);
|
background: rgba(231, 76, 60, 0.12);
|
||||||
}
|
}
|
||||||
.job-item-icon.status-idle {
|
.job-item-icon.status-idle {
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
background: rgba(126, 135, 153, 0.12);
|
background: rgba(126, 135, 153, 0.12);
|
||||||
}
|
}
|
||||||
.job-item-info {
|
.job-item-info {
|
||||||
@@ -332,19 +332,19 @@ onMounted(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.job-item-id {
|
.job-item-id {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
.job-item-schedule {
|
.job-item-schedule {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.job-item-meta {
|
.job-item-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -355,7 +355,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.job-item-meta small {
|
.job-item-meta small {
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.job-status-badge {
|
.job-status-badge {
|
||||||
@@ -368,19 +368,19 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.job-status-badge.status-completed {
|
.job-status-badge.status-completed {
|
||||||
background: rgba(39, 174, 96, 0.15);
|
background: rgba(39, 174, 96, 0.15);
|
||||||
color: var(--st-work);
|
color: #27ae60;
|
||||||
}
|
}
|
||||||
.job-status-badge.status-running {
|
.job-status-badge.status-running {
|
||||||
background: rgba(52, 152, 219, 0.15);
|
background: rgba(52, 152, 219, 0.15);
|
||||||
color: var(--a-blue);
|
color: #3498db;
|
||||||
}
|
}
|
||||||
.job-status-badge.status-failed {
|
.job-status-badge.status-failed {
|
||||||
background: rgba(231, 76, 60, 0.15);
|
background: rgba(231, 76, 60, 0.15);
|
||||||
color: var(--st-block);
|
color: #e74c3c;
|
||||||
}
|
}
|
||||||
.job-status-badge.status-idle {
|
.job-status-badge.status-idle {
|
||||||
background: rgba(126, 135, 153, 0.15);
|
background: rgba(126, 135, 153, 0.15);
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ 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'
|
||||||
@@ -30,6 +31,7 @@ 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 {
|
||||||
@@ -68,6 +70,7 @@ onMounted(() => {
|
|||||||
dashboardStore.startPolling()
|
dashboardStore.startPolling()
|
||||||
taskStore.startPolling()
|
taskStore.startPolling()
|
||||||
taskStore.startBoardPolling()
|
taskStore.startBoardPolling()
|
||||||
|
liveSyncStore.connect()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -76,6 +79,7 @@ onUnmounted(() => {
|
|||||||
dashboardStore.stopPolling()
|
dashboardStore.stopPolling()
|
||||||
taskStore.stopPolling()
|
taskStore.stopPolling()
|
||||||
taskStore.stopBoardPolling()
|
taskStore.stopBoardPolling()
|
||||||
|
liveSyncStore.disconnect()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -203,14 +203,14 @@ onMounted(loadDocs)
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--tx-3);
|
color: #6f7889;
|
||||||
}
|
}
|
||||||
.docs-search-bar input {
|
.docs-search-bar input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
@@ -222,13 +222,13 @@ onMounted(loadDocs)
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--tx-2);
|
color: #8991a1;
|
||||||
}
|
}
|
||||||
.docs-filter-group select {
|
.docs-filter-group select {
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -241,7 +241,7 @@ onMounted(loadDocs)
|
|||||||
}
|
}
|
||||||
.memory-sidebar {
|
.memory-sidebar {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
max-height: 640px;
|
max-height: 640px;
|
||||||
@@ -250,7 +250,7 @@ onMounted(loadDocs)
|
|||||||
.memory-list-header {
|
.memory-list-header {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--a-mid);
|
color: #7065c8;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
padding: 10px 8px 6px;
|
padding: 10px 8px 6px;
|
||||||
@@ -264,7 +264,7 @@ onMounted(loadDocs)
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -280,7 +280,7 @@ onMounted(loadDocs)
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
background: rgba(139,124,246,.1);
|
background: rgba(139,124,246,.1);
|
||||||
}
|
}
|
||||||
.memory-file-info strong {
|
.memory-file-info strong {
|
||||||
@@ -305,41 +305,41 @@ onMounted(loadDocs)
|
|||||||
}
|
}
|
||||||
.doc-category-badge.cat-phases {
|
.doc-category-badge.cat-phases {
|
||||||
background: rgba(139,124,246,.12);
|
background: rgba(139,124,246,.12);
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
}
|
}
|
||||||
.doc-category-badge.cat-skills {
|
.doc-category-badge.cat-skills {
|
||||||
background: rgba(81,212,154,.1);
|
background: rgba(81,212,154,.1);
|
||||||
color: var(--st-work);
|
color: #51d49a;
|
||||||
}
|
}
|
||||||
.doc-category-badge.cat-workspace {
|
.doc-category-badge.cat-workspace {
|
||||||
background: rgba(229,176,94,.1);
|
background: rgba(229,176,94,.1);
|
||||||
color: var(--st-queue);
|
color: #e5b05e;
|
||||||
}
|
}
|
||||||
.doc-category-badge.cat-nexus {
|
.doc-category-badge.cat-nexus {
|
||||||
background: rgba(109,159,230,.1);
|
background: rgba(109,159,230,.1);
|
||||||
color: var(--a-blue);
|
color: #6d9fe6;
|
||||||
}
|
}
|
||||||
.doc-category-badge.cat-nexus-phases {
|
.doc-category-badge.cat-nexus-phases {
|
||||||
background: rgba(225,110,117,.1);
|
background: rgba(225,110,117,.1);
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.doc-type-tag {
|
.doc-type-tag {
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid #343947;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--tx-2);
|
color: #8991a1;
|
||||||
}
|
}
|
||||||
.memory-file-meta {
|
.memory-file-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.memory-content {
|
.memory-content {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
min-height: 480px;
|
min-height: 480px;
|
||||||
@@ -363,7 +363,7 @@ onMounted(loadDocs)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
.memory-back-btn {
|
.memory-back-btn {
|
||||||
@@ -374,13 +374,13 @@ onMounted(loadDocs)
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #8991a1;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.memory-back-btn:hover {
|
.memory-back-btn:hover {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
.memory-status {
|
.memory-status {
|
||||||
@@ -389,11 +389,11 @@ onMounted(loadDocs)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.memory-status.error {
|
.memory-status.error {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.memory-empty-state {
|
.memory-empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -402,27 +402,27 @@ onMounted(loadDocs)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
}
|
}
|
||||||
.memory-empty-state h3 {
|
.memory-empty-state h3 {
|
||||||
margin: 12px 0 6px;
|
margin: 12px 0 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--tx-2);
|
color: #a5adba;
|
||||||
}
|
}
|
||||||
.memory-empty-state p {
|
.memory-empty-state p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.memory-rendered {
|
.memory-rendered {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--tx-2);
|
color: #d0d4dd;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(h1),
|
.memory-rendered :deep(h1),
|
||||||
.memory-rendered :deep(h2),
|
.memory-rendered :deep(h2),
|
||||||
.memory-rendered :deep(h3) {
|
.memory-rendered :deep(h3) {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
margin: 1.2em 0 0.5em;
|
margin: 1.2em 0 0.5em;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
||||||
@@ -438,8 +438,8 @@ onMounted(loadDocs)
|
|||||||
.memory-rendered :deep(pre) {
|
.memory-rendered :deep(pre) {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--space-2);
|
background: #0d1016;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--nx-line);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(pre code) {
|
.memory-rendered :deep(pre code) {
|
||||||
@@ -447,7 +447,7 @@ onMounted(loadDocs)
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(a) {
|
.memory-rendered :deep(a) {
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(a:hover) {
|
.memory-rendered :deep(a:hover) {
|
||||||
@@ -461,11 +461,11 @@ onMounted(loadDocs)
|
|||||||
}
|
}
|
||||||
.memory-rendered :deep(hr) {
|
.memory-rendered :deep(hr) {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--nx-line);
|
||||||
margin: 1.2em 0;
|
margin: 1.2em 0;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(strong) {
|
.memory-rendered :deep(strong) {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ onMounted(loadIncidents)
|
|||||||
}
|
}
|
||||||
.incident-sidebar {
|
.incident-sidebar {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
max-height: 640px;
|
max-height: 640px;
|
||||||
@@ -211,7 +211,7 @@ onMounted(loadIncidents)
|
|||||||
.incident-list-header {
|
.incident-list-header {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--a-mid);
|
color: #7065c8;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
padding: 10px 8px 6px;
|
padding: 10px 8px 6px;
|
||||||
@@ -225,7 +225,7 @@ onMounted(loadIncidents)
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -243,19 +243,19 @@ onMounted(loadIncidents)
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
.incident-file-icon.sev-critical {
|
.incident-file-icon.sev-critical {
|
||||||
color: var(--st-block);
|
color: #e74c3c;
|
||||||
background: rgba(231, 76, 60, 0.12);
|
background: rgba(231, 76, 60, 0.12);
|
||||||
}
|
}
|
||||||
.incident-file-icon.sev-major {
|
.incident-file-icon.sev-major {
|
||||||
color: var(--st-queue);
|
color: #e67e22;
|
||||||
background: rgba(230, 126, 34, 0.12);
|
background: rgba(230, 126, 34, 0.12);
|
||||||
}
|
}
|
||||||
.incident-file-icon.sev-minor {
|
.incident-file-icon.sev-minor {
|
||||||
color: var(--st-queue);
|
color: #f1c40f;
|
||||||
background: rgba(241, 196, 15, 0.12);
|
background: rgba(241, 196, 15, 0.12);
|
||||||
}
|
}
|
||||||
.incident-file-icon.sev-unknown {
|
.incident-file-icon.sev-unknown {
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
background: rgba(126, 135, 153, 0.12);
|
background: rgba(126, 135, 153, 0.12);
|
||||||
}
|
}
|
||||||
.incident-file-info strong {
|
.incident-file-info strong {
|
||||||
@@ -269,7 +269,7 @@ onMounted(loadIncidents)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.incident-file-excerpt {
|
.incident-file-excerpt {
|
||||||
@@ -278,7 +278,7 @@ onMounted(loadIncidents)
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.severity-badge {
|
.severity-badge {
|
||||||
@@ -291,23 +291,23 @@ onMounted(loadIncidents)
|
|||||||
}
|
}
|
||||||
.severity-badge.sev-critical {
|
.severity-badge.sev-critical {
|
||||||
background: rgba(231, 76, 60, 0.15);
|
background: rgba(231, 76, 60, 0.15);
|
||||||
color: var(--st-block);
|
color: #e74c3c;
|
||||||
}
|
}
|
||||||
.severity-badge.sev-major {
|
.severity-badge.sev-major {
|
||||||
background: rgba(230, 126, 34, 0.15);
|
background: rgba(230, 126, 34, 0.15);
|
||||||
color: var(--st-queue);
|
color: #e67e22;
|
||||||
}
|
}
|
||||||
.severity-badge.sev-minor {
|
.severity-badge.sev-minor {
|
||||||
background: rgba(241, 196, 15, 0.15);
|
background: rgba(241, 196, 15, 0.15);
|
||||||
color: var(--st-queue);
|
color: #f1c40f;
|
||||||
}
|
}
|
||||||
.severity-badge.sev-unknown {
|
.severity-badge.sev-unknown {
|
||||||
background: rgba(126, 135, 153, 0.15);
|
background: rgba(126, 135, 153, 0.15);
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.incident-content {
|
.incident-content {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
min-height: 480px;
|
min-height: 480px;
|
||||||
@@ -335,7 +335,7 @@ onMounted(loadIncidents)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.incident-back-btn {
|
.incident-back-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -345,13 +345,13 @@ onMounted(loadIncidents)
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #8991a1;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.incident-back-btn:hover {
|
.incident-back-btn:hover {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
.incident-status {
|
.incident-status {
|
||||||
@@ -360,11 +360,11 @@ onMounted(loadIncidents)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.incident-status.error {
|
.incident-status.error {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.incident-empty-state {
|
.incident-empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -373,27 +373,27 @@ onMounted(loadIncidents)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
}
|
}
|
||||||
.incident-empty-state h3 {
|
.incident-empty-state h3 {
|
||||||
margin: 12px 0 6px;
|
margin: 12px 0 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--tx-2);
|
color: #a5adba;
|
||||||
}
|
}
|
||||||
.incident-empty-state p {
|
.incident-empty-state p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.incident-rendered {
|
.incident-rendered {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--tx-2);
|
color: #d0d4dd;
|
||||||
}
|
}
|
||||||
.incident-rendered :deep(h1),
|
.incident-rendered :deep(h1),
|
||||||
.incident-rendered :deep(h2),
|
.incident-rendered :deep(h2),
|
||||||
.incident-rendered :deep(h3) {
|
.incident-rendered :deep(h3) {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
margin: 1.2em 0 0.5em;
|
margin: 1.2em 0 0.5em;
|
||||||
}
|
}
|
||||||
.incident-rendered :deep(h1) { font-size: 1.3rem; }
|
.incident-rendered :deep(h1) { font-size: 1.3rem; }
|
||||||
@@ -409,8 +409,8 @@ onMounted(loadIncidents)
|
|||||||
.incident-rendered :deep(pre) {
|
.incident-rendered :deep(pre) {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--space-2);
|
background: #0d1016;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--nx-line);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.incident-rendered :deep(pre code) {
|
.incident-rendered :deep(pre code) {
|
||||||
@@ -418,7 +418,7 @@ onMounted(loadIncidents)
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.incident-rendered :deep(a) {
|
.incident-rendered :deep(a) {
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.incident-rendered :deep(a:hover) {
|
.incident-rendered :deep(a:hover) {
|
||||||
@@ -432,11 +432,11 @@ onMounted(loadIncidents)
|
|||||||
}
|
}
|
||||||
.incident-rendered :deep(hr) {
|
.incident-rendered :deep(hr) {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--nx-line);
|
||||||
margin: 1.2em 0;
|
margin: 1.2em 0;
|
||||||
}
|
}
|
||||||
.incident-rendered :deep(strong) {
|
.incident-rendered :deep(strong) {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
|||||||
@@ -227,22 +227,22 @@ onMounted(loadMemories)
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
color: var(--tx-3);
|
color: #6f7889;
|
||||||
}
|
}
|
||||||
.memory-search-bar input {
|
.memory-search-bar input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.memory-search-bar kbd {
|
.memory-search-bar kbd {
|
||||||
padding: 2px 5px;
|
padding: 2px 5px;
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid #2c313d;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: var(--tx-3);
|
color: #606979;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
.memory-layout {
|
.memory-layout {
|
||||||
@@ -253,7 +253,7 @@ onMounted(loadMemories)
|
|||||||
}
|
}
|
||||||
.memory-sidebar {
|
.memory-sidebar {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
max-height: 640px;
|
max-height: 640px;
|
||||||
@@ -262,7 +262,7 @@ onMounted(loadMemories)
|
|||||||
.memory-list-header {
|
.memory-list-header {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--a-mid);
|
color: #7065c8;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
padding: 10px 8px 6px;
|
padding: 10px 8px 6px;
|
||||||
@@ -276,7 +276,7 @@ onMounted(loadMemories)
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -292,7 +292,7 @@ onMounted(loadMemories)
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
background: rgba(139,124,246,.1);
|
background: rgba(139,124,246,.1);
|
||||||
}
|
}
|
||||||
.memory-file-info strong {
|
.memory-file-info strong {
|
||||||
@@ -307,7 +307,7 @@ onMounted(loadMemories)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.memory-file-excerpt {
|
.memory-file-excerpt {
|
||||||
@@ -318,7 +318,7 @@ onMounted(loadMemories)
|
|||||||
}
|
}
|
||||||
.memory-content {
|
.memory-content {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
min-height: 480px;
|
min-height: 480px;
|
||||||
@@ -342,7 +342,7 @@ onMounted(loadMemories)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.memory-back-btn {
|
.memory-back-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -352,13 +352,13 @@ onMounted(loadMemories)
|
|||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #8991a1;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.memory-back-btn:hover {
|
.memory-back-btn:hover {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
.memory-status {
|
.memory-status {
|
||||||
@@ -367,11 +367,11 @@ onMounted(loadMemories)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.memory-status.error {
|
.memory-status.error {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.memory-empty-state {
|
.memory-empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -380,27 +380,27 @@ onMounted(loadMemories)
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
}
|
}
|
||||||
.memory-empty-state h3 {
|
.memory-empty-state h3 {
|
||||||
margin: 12px 0 6px;
|
margin: 12px 0 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--tx-2);
|
color: #a5adba;
|
||||||
}
|
}
|
||||||
.memory-empty-state p {
|
.memory-empty-state p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.memory-rendered {
|
.memory-rendered {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
color: var(--tx-2);
|
color: #d0d4dd;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(h1),
|
.memory-rendered :deep(h1),
|
||||||
.memory-rendered :deep(h2),
|
.memory-rendered :deep(h2),
|
||||||
.memory-rendered :deep(h3) {
|
.memory-rendered :deep(h3) {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
margin: 1.2em 0 0.5em;
|
margin: 1.2em 0 0.5em;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
||||||
@@ -416,8 +416,8 @@ onMounted(loadMemories)
|
|||||||
.memory-rendered :deep(pre) {
|
.memory-rendered :deep(pre) {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--space-2);
|
background: #0d1016;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--nx-line);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(pre code) {
|
.memory-rendered :deep(pre code) {
|
||||||
@@ -425,7 +425,7 @@ onMounted(loadMemories)
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(a) {
|
.memory-rendered :deep(a) {
|
||||||
color: var(--a-mid);
|
color: #a99cf5;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(a:hover) {
|
.memory-rendered :deep(a:hover) {
|
||||||
@@ -439,11 +439,11 @@ onMounted(loadMemories)
|
|||||||
}
|
}
|
||||||
.memory-rendered :deep(hr) {
|
.memory-rendered :deep(hr) {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--nx-line);
|
||||||
margin: 1.2em 0;
|
margin: 1.2em 0;
|
||||||
}
|
}
|
||||||
.memory-rendered :deep(strong) {
|
.memory-rendered :deep(strong) {
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
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(
|
||||||
@@ -24,10 +26,10 @@ function typeIcon(type: string): string {
|
|||||||
|
|
||||||
function typeColor(type: string): string {
|
function typeColor(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'task_assigned': return '#4f7cff'
|
case 'task_assigned': return '#4d8cf6'
|
||||||
case 'task_review': return '#fbbf24'
|
case 'task_review': return '#f6a84d'
|
||||||
case 'task_blocked': return '#fb7185'
|
case 'task_blocked': return '#e16e75'
|
||||||
default: return '#7c6cff'
|
default: return '#7b6ef2'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,10 +55,12 @@ 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>
|
||||||
|
|
||||||
@@ -133,18 +137,18 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--nx-line, #1f2330);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-3);
|
color: var(--nx-text-dim, #6f7889);
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s, color .15s;
|
transition: background .15s, color .15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mark-all-btn:hover {
|
.mark-all-btn:hover {
|
||||||
background: rgba(124, 108, 255, 0.08);
|
background: var(--nx-accent-soft, rgba(123, 110, 242, .08));
|
||||||
color: var(--tx);
|
color: #d8dbe3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty State ── */
|
/* ── Empty State ── */
|
||||||
@@ -154,7 +158,7 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 80px 0;
|
padding: 80px 0;
|
||||||
color: var(--tx-3);
|
color: var(--nx-text-dim, #6f7889);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +186,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-card:hover {
|
.notification-card:hover {
|
||||||
background: rgba(124, 108, 255, 0.06);
|
background: var(--nx-accent-soft, rgba(123, 110, 242, .06));
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-card.unread {
|
.notification-card.unread {
|
||||||
@@ -211,7 +215,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--tx);
|
color: #d8dbe3;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +226,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.card-message {
|
.card-message {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: var(--tx-3);
|
color: var(--nx-text-dim, #6f7889);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
@@ -238,12 +242,12 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.timestamp {
|
.timestamp {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--tx-3);
|
color: var(--nx-text-dim, #6f7889);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
color: var(--tx-3);
|
color: var(--nx-text-dim, #6f7889);
|
||||||
opacity: .5;
|
opacity: .5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ onMounted(loadProject)
|
|||||||
}
|
}
|
||||||
.project-detail-card {
|
.project-detail-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@ onMounted(loadProject)
|
|||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: linear-gradient(135deg, var(--a-mid), var(--accent-secondary));
|
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -273,7 +273,7 @@ onMounted(loadProject)
|
|||||||
}
|
}
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -282,8 +282,8 @@ onMounted(loadProject)
|
|||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
background: rgba(124, 108, 255, 0.10);
|
background: var(--nx-accent-soft);
|
||||||
color: var(--a-mid);
|
color: var(--nx-accent);
|
||||||
}
|
}
|
||||||
.btn-icon.btn-danger:hover {
|
.btn-icon.btn-danger:hover {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
@@ -294,7 +294,7 @@ onMounted(loadProject)
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -304,7 +304,7 @@ onMounted(loadProject)
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -321,7 +321,7 @@ onMounted(loadProject)
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding: 0.4rem 0.8rem;
|
padding: 0.4rem 0.8rem;
|
||||||
background: var(--a-mid);
|
background: var(--nx-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -335,7 +335,7 @@ onMounted(loadProject)
|
|||||||
padding: 0.4rem 0.8rem;
|
padding: 0.4rem 0.8rem;
|
||||||
background: var(--surface-raised);
|
background: var(--surface-raised);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -343,7 +343,7 @@ onMounted(loadProject)
|
|||||||
.progress-section {
|
.progress-section {
|
||||||
margin-top: 1.25rem;
|
margin-top: 1.25rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
border-top: 1px solid var(--line-2);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.progress-header {
|
.progress-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -361,7 +361,7 @@ onMounted(loadProject)
|
|||||||
.progress-bar i {
|
.progress-bar i {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, var(--a-mid), var(--accent-secondary));
|
background: linear-gradient(90deg, var(--nx-accent), var(--accent-secondary));
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,7 @@ onMounted(loadProject)
|
|||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px dashed var(--line-2);
|
border: 1px dashed var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.task-list {
|
.task-list {
|
||||||
@@ -394,7 +394,7 @@ onMounted(loadProject)
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.task-icon {
|
.task-icon {
|
||||||
@@ -403,7 +403,7 @@ onMounted(loadProject)
|
|||||||
.task-icon.done { color: rgb(34, 197, 94); }
|
.task-icon.done { color: rgb(34, 197, 94); }
|
||||||
.task-icon.blocked { color: rgb(239, 68, 68); }
|
.task-icon.blocked { color: rgb(239, 68, 68); }
|
||||||
.task-icon.backlog { color: var(--text-muted); }
|
.task-icon.backlog { color: var(--text-muted); }
|
||||||
.task-icon.in-progress { color: var(--a-mid); }
|
.task-icon.in-progress { color: var(--nx-accent); }
|
||||||
.task-info {
|
.task-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -441,7 +441,7 @@ onMounted(loadProject)
|
|||||||
}
|
}
|
||||||
.modal {
|
.modal {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ onMounted(loadStatus)
|
|||||||
}
|
}
|
||||||
.security-card {
|
.security-card {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -204,27 +204,27 @@ onMounted(loadStatus)
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: var(--r);
|
border-radius: 9px;
|
||||||
color: var(--a-mid);
|
color: #a99cff;
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
.security-card-icon.twofa-icon {
|
.security-card-icon.twofa-icon {
|
||||||
color: var(--st-queue);
|
color: #e5b05e;
|
||||||
background: rgba(229,176,94,.1);
|
background: rgba(229,176,94,.1);
|
||||||
}
|
}
|
||||||
.security-card-icon.passkey-icon {
|
.security-card-icon.passkey-icon {
|
||||||
color: var(--a-blue);
|
color: #6d9fe6;
|
||||||
background: rgba(109,159,230,.1);
|
background: rgba(109,159,230,.1);
|
||||||
}
|
}
|
||||||
.security-card h3 {
|
.security-card h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.security-value {
|
.security-value {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
}
|
}
|
||||||
.policy-text {
|
.policy-text {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -234,7 +234,7 @@ onMounted(loadStatus)
|
|||||||
.security-desc {
|
.security-desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.security-detail-list {
|
.security-detail-list {
|
||||||
@@ -250,14 +250,14 @@ onMounted(loadStatus)
|
|||||||
}
|
}
|
||||||
.security-detail-label {
|
.security-detail-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-2);
|
color: #8991a1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
.security-detail-value {
|
.security-detail-value {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -281,11 +281,11 @@ code.security-detail-value {
|
|||||||
}
|
}
|
||||||
.status-bool.enabled {
|
.status-bool.enabled {
|
||||||
background: rgba(81,212,154,.1);
|
background: rgba(81,212,154,.1);
|
||||||
color: var(--st-work);
|
color: #51d49a;
|
||||||
}
|
}
|
||||||
.status-bool.disabled {
|
.status-bool.disabled {
|
||||||
background: rgba(225,110,117,.08);
|
background: rgba(225,110,117,.08);
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.security-status-row {
|
.security-status-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -293,16 +293,16 @@ code.security-detail-value {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.check-icon {
|
.check-icon {
|
||||||
color: var(--st-work);
|
color: #51d49a;
|
||||||
}
|
}
|
||||||
.x-icon {
|
.x-icon {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.enabled-text {
|
.enabled-text {
|
||||||
color: var(--st-work);
|
color: #51d49a;
|
||||||
}
|
}
|
||||||
.disabled-text {
|
.disabled-text {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.memory-status {
|
.memory-status {
|
||||||
@@ -311,11 +311,11 @@ code.security-detail-value {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 48px;
|
padding: 48px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.memory-status.error {
|
.memory-status.error {
|
||||||
color: var(--st-block);
|
color: #e16e75;
|
||||||
}
|
}
|
||||||
.spin {
|
.spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.grad-text {
|
.grad-text {
|
||||||
background: linear-gradient(135deg, var(--a-blue), var(--a-purple));
|
background: linear-gradient(135deg, #4f7cff, #b557f6);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
@@ -418,7 +418,7 @@ onMounted(() => {
|
|||||||
.settings-subtitle {
|
.settings-subtitle {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
padding-bottom: 14px;
|
padding-bottom: 14px;
|
||||||
border-bottom: 1px solid rgba(150, 140, 255, 0.08);
|
border-bottom: 1px solid rgba(150, 140, 255, 0.08);
|
||||||
}
|
}
|
||||||
@@ -470,7 +470,7 @@ onMounted(() => {
|
|||||||
.field label {
|
.field label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -490,7 +490,7 @@ onMounted(() => {
|
|||||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: rgba(10, 9, 24, 0.55);
|
background: rgba(10, 9, 24, 0.55);
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -513,13 +513,13 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.galaxy-select option {
|
.galaxy-select option {
|
||||||
background: var(--space-3);
|
background: #141130;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value-tag {
|
.value-tag {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,7 +538,7 @@ onMounted(() => {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
background: linear-gradient(135deg, rgba(79, 124, 255, 0.15), rgba(181, 87, 246, 0.15));
|
background: linear-gradient(135deg, rgba(79, 124, 255, 0.15), rgba(181, 87, 246, 0.15));
|
||||||
color: var(--a-mid);
|
color: #b8adff;
|
||||||
border: 1px solid rgba(124, 108, 255, 0.15);
|
border: 1px solid rgba(124, 108, 255, 0.15);
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
@@ -589,14 +589,14 @@ onMounted(() => {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pw-toggle:hover {
|
.pw-toggle:hover {
|
||||||
background: rgba(124, 108, 255, 0.08);
|
background: rgba(124, 108, 255, 0.08);
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Messages ──────────────────────────────────── */
|
/* ── Messages ──────────────────────────────────── */
|
||||||
@@ -613,13 +613,13 @@ onMounted(() => {
|
|||||||
.msg.error {
|
.msg.error {
|
||||||
background: rgba(244, 63, 94, 0.1);
|
background: rgba(244, 63, 94, 0.1);
|
||||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||||
color: var(--st-block);
|
color: #fda4af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg.success {
|
.msg.success {
|
||||||
background: rgba(34, 197, 94, 0.1);
|
background: rgba(34, 197, 94, 0.1);
|
||||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||||
color: var(--st-work);
|
color: #86efac;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Buttons ──────────────────────────────────── */
|
/* ── Buttons ──────────────────────────────────── */
|
||||||
@@ -630,7 +630,7 @@ onMounted(() => {
|
|||||||
padding: 9px 16px;
|
padding: 9px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: linear-gradient(135deg, var(--a-blue), var(--a-mid), var(--a-purple));
|
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -665,7 +665,7 @@ onMounted(() => {
|
|||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
@@ -673,7 +673,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.btn-ghost:hover {
|
.btn-ghost:hover {
|
||||||
background: rgba(124, 108, 255, 0.08);
|
background: rgba(124, 108, 255, 0.08);
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admin User Management ───────────────────── */
|
/* ── Admin User Management ───────────────────── */
|
||||||
@@ -711,7 +711,7 @@ onMounted(() => {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(181, 87, 246, 0.2));
|
background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(181, 87, 246, 0.2));
|
||||||
border: 1px solid rgba(124, 108, 255, 0.15);
|
border: 1px solid rgba(124, 108, 255, 0.15);
|
||||||
color: var(--a-mid);
|
color: #b8adff;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -725,12 +725,12 @@ onMounted(() => {
|
|||||||
.user-name {
|
.user-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-email {
|
.user-email {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -754,31 +754,31 @@ onMounted(() => {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
background: rgba(124, 108, 255, 0.1);
|
background: rgba(124, 108, 255, 0.1);
|
||||||
color: var(--a-mid);
|
color: #b8adff;
|
||||||
border: 1px solid rgba(124, 108, 255, 0.1);
|
border: 1px solid rgba(124, 108, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-tag.owner {
|
.role-tag.owner {
|
||||||
background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(251, 191, 36, 0.15));
|
background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(251, 191, 36, 0.15));
|
||||||
color: var(--st-queue);
|
color: #fde68a;
|
||||||
border-color: rgba(251, 191, 36, 0.2);
|
border-color: rgba(251, 191, 36, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-tag.admin {
|
.role-tag.admin {
|
||||||
background: rgba(79, 124, 255, 0.12);
|
background: rgba(79, 124, 255, 0.12);
|
||||||
color: var(--a-blue);
|
color: #93c5fd;
|
||||||
border-color: rgba(79, 124, 255, 0.2);
|
border-color: rgba(79, 124, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-tag.viewer {
|
.role-tag.viewer {
|
||||||
background: rgba(107, 103, 150, 0.15);
|
background: rgba(107, 103, 150, 0.15);
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
border-color: rgba(107, 103, 150, 0.2);
|
border-color: rgba(107, 103, 150, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-date {
|
.user-date {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-pulse {
|
.loading-pulse {
|
||||||
@@ -806,7 +806,7 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
@@ -849,7 +849,7 @@ onMounted(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,7 +861,7 @@ onMounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
@@ -870,11 +870,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
background: rgba(124, 108, 255, 0.08);
|
background: rgba(124, 108, 255, 0.08);
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.req {
|
.req {
|
||||||
color: var(--st-block);
|
color: #fb7185;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-form {
|
.modal-form {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -111,9 +111,9 @@ function statusClass(state: string): string {
|
|||||||
|
|
||||||
function priorityColor(p: string): string {
|
function priorityColor(p: string): string {
|
||||||
const lower = p.toLowerCase()
|
const lower = p.toLowerCase()
|
||||||
if (lower === 'high') return 'var(--st-block)'
|
if (lower === 'high') return '#f87171'
|
||||||
if (lower === 'low') return 'var(--a-blue)'
|
if (lower === 'low') return '#60a5fa'
|
||||||
return 'var(--st-queue)'
|
return '#facc15'
|
||||||
}
|
}
|
||||||
|
|
||||||
function priorityLabel(p: string): string {
|
function priorityLabel(p: string): string {
|
||||||
@@ -638,7 +638,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -648,7 +648,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
.back-btn:hover {
|
.back-btn:hover {
|
||||||
background: rgba(124, 108, 255, 0.08);
|
background: rgba(124, 108, 255, 0.08);
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Loading / Error ────────────────────────── */
|
/* ── Loading / Error ────────────────────────── */
|
||||||
@@ -659,14 +659,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 80px 20px;
|
padding: 80px 20px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: 2.5px solid rgba(150, 140, 255, 0.15);
|
border: 2.5px solid rgba(150, 140, 255, 0.15);
|
||||||
border-top-color: var(--a-mid);
|
border-top-color: #7c6cff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -675,7 +675,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
border: 2px solid rgba(150, 140, 255, 0.15);
|
border: 2px solid rgba(150, 140, 255, 0.15);
|
||||||
border-top-color: var(--a-mid);
|
border-top-color: #7c6cff;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -686,7 +686,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
.error-state p {
|
.error-state p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--st-block);
|
color: #fda4af;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Detail Header ──────────────────────────── */
|
/* ── Detail Header ──────────────────────────── */
|
||||||
@@ -711,15 +711,15 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-badge.is-backlog { color: var(--st-queue); background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
|
.state-badge.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
|
||||||
.state-badge.is-progress { color: var(--st-work); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
.state-badge.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
||||||
.state-badge.is-review { color: var(--st-queue); background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
|
.state-badge.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
|
||||||
.state-badge.is-blocked { color: var(--st-block); background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
|
.state-badge.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
|
||||||
.state-badge.is-done { color: var(--st-work); background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
.state-badge.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
||||||
|
|
||||||
.meta-chip {
|
.meta-chip {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
background: rgba(10, 9, 24, 0.35);
|
background: rgba(10, 9, 24, 0.35);
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -735,7 +735,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(147,51,234,.08);
|
background: rgba(147,51,234,.08);
|
||||||
border: 1px solid rgba(147,51,234,.18);
|
border: 1px solid rgba(147,51,234,.18);
|
||||||
color: var(--a-purple);
|
color: #c084fc;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -759,7 +759,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: none;
|
border: none;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -777,7 +777,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-row span {
|
.meta-row span {
|
||||||
@@ -791,7 +791,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(124, 108, 255, 0.08);
|
background: rgba(124, 108, 255, 0.08);
|
||||||
border: 1px solid rgba(124, 108, 255, 0.14);
|
border: 1px solid rgba(124, 108, 255, 0.14);
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,7 +808,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -826,7 +826,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: 1px solid rgba(150, 140, 255, 0.10);
|
border: 1px solid rgba(150, 140, 255, 0.10);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(10, 9, 24, 0.45);
|
background: rgba(10, 9, 24, 0.45);
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
@@ -897,13 +897,13 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
.subtask-title {
|
.subtask-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtask-detail {
|
.subtask-detail {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,7 +919,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: rgba(10, 9, 24, 0.5);
|
background: rgba(10, 9, 24, 0.5);
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -927,8 +927,8 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.state-select-mini option {
|
.state-select-mini option {
|
||||||
background: var(--space-3);
|
background: #141130;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-select-mini:disabled {
|
.state-select-mini:disabled {
|
||||||
@@ -941,14 +941,14 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-section {
|
.empty-section {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -996,7 +996,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: rgba(124, 108, 255, 0.08);
|
background: rgba(124, 108, 255, 0.08);
|
||||||
color: var(--a-mid);
|
color: #7c6cff;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,13 +1007,13 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
|
|
||||||
.activity-msg {
|
.activity-msg {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.activity-time {
|
.activity-time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,7 +1042,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1060,7 +1060,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
.sidebar-field span {
|
.sidebar-field span {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
@@ -1079,13 +1079,13 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-list dt {
|
.info-list dt {
|
||||||
color: var(--tx-3);
|
color: #6f6aa0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-list dd {
|
.info-list dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -1104,7 +1104,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(10, 9, 24, 0.55);
|
background: rgba(10, 9, 24, 0.55);
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
font-size: 13.5px;
|
font-size: 13.5px;
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -1127,8 +1127,8 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.galaxy-select option {
|
.galaxy-select option {
|
||||||
background: var(--space-3);
|
background: #141130;
|
||||||
color: var(--tx);
|
color: #ece9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.galaxy-select:disabled {
|
.galaxy-select:disabled {
|
||||||
@@ -1143,7 +1143,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
padding: 9px 16px;
|
padding: 9px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: linear-gradient(135deg, var(--a-blue), var(--a-mid), var(--a-purple));
|
background: linear-gradient(135deg, #4f7cff, #7c6cff, #b557f6);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -1169,7 +1169,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'Manrope', sans-serif;
|
font-family: 'Manrope', sans-serif;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1186,13 +1186,13 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: rgba(124, 108, 255, 0.06);
|
background: rgba(124, 108, 255, 0.06);
|
||||||
color: var(--tx-2);
|
color: #a8a3d6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon-sm:hover { background: rgba(124, 108, 255, 0.12); color: var(--tx); }
|
.btn-icon-sm:hover { background: rgba(124, 108, 255, 0.12); color: #ece9ff; }
|
||||||
.btn-icon-sm.danger:hover { background: rgba(244, 63, 94, 0.12); color: var(--st-block); }
|
.btn-icon-sm.danger:hover { background: rgba(244, 63, 94, 0.12); color: #fda4af; }
|
||||||
.btn-icon-sm:disabled { opacity: 0.4; cursor: not-allowed; }
|
.btn-icon-sm:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
.msg {
|
.msg {
|
||||||
@@ -1202,8 +1202,8 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg.error { background: rgba(244, 63, 94, 0.08); border: 1px solid rgba(244, 63, 94, 0.15); color: var(--st-block); }
|
.msg.error { background: rgba(244, 63, 94, 0.08); border: 1px solid rgba(244, 63, 94, 0.15); color: #fda4af; }
|
||||||
.msg.success { background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.15); color: var(--st-work); display: flex; align-items: center; gap: 6px; }
|
.msg.success { background: rgba(34, 197, 94, 0.08); border: 1px solid rgba(34, 197, 94, 0.15); color: #86efac; display: flex; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────────── */
|
/* ── Responsive ──────────────────────────────── */
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const agents: AgentCardData[] = [
|
|||||||
role: 'Chief of Staff',
|
role: 'Chief of Staff',
|
||||||
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
||||||
tags: ['Orchestration', 'Delegation', 'Approval'],
|
tags: ['Orchestration', 'Delegation', 'Approval'],
|
||||||
color: '#7c6cff',
|
color: '#8b7cf6',
|
||||||
icon: 'bot',
|
icon: 'bot',
|
||||||
hero: true,
|
hero: true,
|
||||||
},
|
},
|
||||||
@@ -33,7 +33,7 @@ const agents: AgentCardData[] = [
|
|||||||
role: 'Lead Developer',
|
role: 'Lead Developer',
|
||||||
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
||||||
tags: ['coding', 'development', 'builds'],
|
tags: ['coding', 'development', 'builds'],
|
||||||
color: '#4f7cff',
|
color: '#3b82f6',
|
||||||
icon: 'code',
|
icon: 'code',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,7 +42,7 @@ const agents: AgentCardData[] = [
|
|||||||
role: 'Code QA',
|
role: 'Code QA',
|
||||||
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
||||||
tags: ['Quality Assurance', 'Security', 'Code Review'],
|
tags: ['Quality Assurance', 'Security', 'Code Review'],
|
||||||
color: '#b557f6',
|
color: '#a855f7',
|
||||||
icon: 'shield',
|
icon: 'shield',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -51,7 +51,7 @@ const agents: AgentCardData[] = [
|
|||||||
role: 'Research Analyst',
|
role: 'Research Analyst',
|
||||||
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
||||||
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
||||||
color: '#3ddc97',
|
color: '#22c55e',
|
||||||
icon: 'search',
|
icon: 'search',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -60,7 +60,7 @@ const agents: AgentCardData[] = [
|
|||||||
role: 'Host Executor',
|
role: 'Host Executor',
|
||||||
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
||||||
tags: ['Execution', 'Deployment', 'VPS'],
|
tags: ['Execution', 'Deployment', 'VPS'],
|
||||||
color: '#fbbf24',
|
color: '#eab308',
|
||||||
icon: 'terminal',
|
icon: 'terminal',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -133,7 +133,7 @@ function goToAgent(id: string) {
|
|||||||
.quote-text {
|
.quote-text {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--tx-2);
|
color: #9ea5b3;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,17 +144,17 @@ function goToAgent(id: string) {
|
|||||||
.team-title {
|
.team-title {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--tx);
|
color: #e8eaf0;
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
}
|
}
|
||||||
.team-subtitle {
|
.team-subtitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
.team-description {
|
.team-description {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
color: var(--tx-3);
|
color: #6b7385;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -182,7 +182,7 @@ function goToAgent(id: string) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: #7e8799;
|
||||||
}
|
}
|
||||||
.legend-dot {
|
.legend-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -191,11 +191,11 @@ function goToAgent(id: string) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.active-pulse {
|
.active-pulse {
|
||||||
background: var(--st-work);
|
background: #51d49a;
|
||||||
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
|
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
|
||||||
}
|
}
|
||||||
.idle-pulse {
|
.idle-pulse {
|
||||||
background: var(--line-2);
|
background: #3a3f4b;
|
||||||
}
|
}
|
||||||
.pulse-dot {
|
.pulse-dot {
|
||||||
background: white;
|
background: white;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Runtime und Routing
|
# Runtime und Routing
|
||||||
|
|
||||||
> Letzte Aktualisierung: 2026-07-09
|
> Letzte Aktualisierung: 2026-06-16
|
||||||
|
|
||||||
## Aktive Modelle (7 von 8 konfiguriert)
|
## Aktive Modelle (7 von 8 konfiguriert)
|
||||||
|
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
|
|
||||||
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
|
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
|
||||||
- Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
|
- Model-Routing läuft zentral über OpenClaw Gateway (kein direct provider routing)
|
||||||
- API kommuniziert über Docker-DNS via `http://openclaw-gateway-bao:18789` im gemeinsamen `openclaw_default`-Netzwerk.
|
- API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)
|
||||||
- Der frühere Pfad `host.docker.internal:18789` ist auf dem VPS nicht erreichbar und wurde entfernt.
|
|
||||||
- **Achtung:** `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` muss durch ApiKey-Auth ersetzt werden (siehe [Architektur-Review](../docs/architecture-board-first-orchestration.md#61-kritisch--allowanonymous-auf-board-endpunkten))
|
- **Achtung:** `[AllowAnonymous]` auf `/tasks/board` und `/tasks/reset-stale` muss durch ApiKey-Auth ersetzt werden (siehe [Architektur-Review](../docs/architecture-board-first-orchestration.md#61-kritisch--allowanonymous-auf-board-endpunkten))
|
||||||
- Vollständige Architektur- und Sicherheitsanalyse: [architecture-board-first-orchestration.md](../docs/architecture-board-first-orchestration.md)
|
- Vollständige Architektur- und Sicherheitsanalyse: [architecture-board-first-orchestration.md](../docs/architecture-board-first-orchestration.md)
|
||||||
|
|||||||
Reference in New Issue
Block a user