Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ad8c956eb |
+2
-2
@@ -19,7 +19,7 @@ JWT_AUDIENCE=nexus-web
|
||||
BOOTSTRAP_OWNER_EMAIL=***
|
||||
|
||||
# ── OpenClaw Integration ────────────────────────────────
|
||||
# Internal Docker-DNS URL of the OpenClaw gateway
|
||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||
# Base URL of the OpenClaw gateway (host.docker.internal from inside container)
|
||||
OPENCLAW_BASE_URL=http://host.docker.internal:18789
|
||||
OPENCLAW_GATEWAY_TOKEN=***
|
||||
OPENCLAW_GATEWAY_PASSWORD=***
|
||||
|
||||
@@ -60,7 +60,7 @@ JWT_KEY=${ENV_JWT_KEY}
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
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_PASSWORD=
|
||||
NEXUS_VERSION=${VERSION}
|
||||
@@ -105,6 +105,40 @@ git archive --format=tar HEAD | docker run --rm -i \
|
||||
chown -R "$dest_owner" /dest
|
||||
'
|
||||
|
||||
# ── Sanitized agents config for Nexus (no secrets) ──
|
||||
echo "Generating sanitized agents config for Nexus (no secrets from openclaw.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_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 "
|
||||
import json, sys, os
|
||||
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)
|
||||
agents = data.get('agents')
|
||||
if agents is None:
|
||||
print('ERROR: \"agents\" key not found in openclaw.json', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(output_path, 'w') as f:
|
||||
json.dump({'agents': agents}, f, indent=2)
|
||||
f.write('\n')
|
||||
print(f'Sanitized agents config written ({len(agents.get(\"list\", []))} agents)')
|
||||
" 2>&1; then
|
||||
echo "Sanitized agents config written to $AGENTS_SANITIZED_PATH"
|
||||
else
|
||||
echo "WARNING: Failed to generate agents-sanitized.json — Nexus will use fallback agent IDs" >&2
|
||||
fi
|
||||
|
||||
echo "Building and starting Docker compose stack"
|
||||
docker run --rm \
|
||||
-v "$DEPLOY_PATH:/workspace/nexus" \
|
||||
@@ -140,15 +174,9 @@ echo "Checking live health"
|
||||
retry=0
|
||||
while [ "$retry" -lt 6 ]; do
|
||||
retry=$((retry + 1))
|
||||
health_body="$(curl -fsS --max-time 10 "$BASE_URL/health" 2>/dev/null || true)"
|
||||
case "$health_body" in
|
||||
'{"status":"Healthy"'*)
|
||||
if curl -fsS --max-time 10 "$BASE_URL/health" >/dev/null; then
|
||||
echo "Health check passed"
|
||||
break
|
||||
;;
|
||||
esac
|
||||
if [ -n "$health_body" ]; then
|
||||
echo "Health endpoint is reachable but not healthy: $health_body" >&2
|
||||
fi
|
||||
if [ "$retry" -eq 6 ]; then
|
||||
echo "Health check failed" >&2
|
||||
|
||||
@@ -112,11 +112,9 @@ jobs:
|
||||
JWT_ISSUER=nexus
|
||||
JWT_AUDIENCE=nexus-web
|
||||
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_PASSWORD=
|
||||
NEXUS_VERSION=$(tr -d '[:space:]' < VERSION)
|
||||
NEXUS_GIT_SHA=$(git rev-parse HEAD)
|
||||
EOF
|
||||
|
||||
chmod 600 "${ENV_TMPFILE}"
|
||||
@@ -129,38 +127,18 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git archive --format=tar HEAD | docker run --rm -i \
|
||||
docker run --rm \
|
||||
-v "${{ gitea.workspace }}:/src:ro" \
|
||||
-v "${DEPLOY_PATH}:/dest" \
|
||||
alpine:latest \
|
||||
sh -c '
|
||||
set -eu
|
||||
dest_owner="$(stat -c "%u:%g" /dest)"
|
||||
mkdir -p /src-snapshot
|
||||
tar -xf - -C /src-snapshot
|
||||
|
||||
is_protected_path() {
|
||||
case "$1" in
|
||||
./.git|./.env|./.env.*|./data|./logs|./backups|./tmp|./uploads|./storage)
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cd /dest
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then rm -rf "$path"; fi
|
||||
done
|
||||
|
||||
cd /src-snapshot
|
||||
find . -mindepth 1 -maxdepth 1 | while IFS= read -r path; do
|
||||
if ! is_protected_path "$path"; then cp -a "$path" /dest/; fi
|
||||
done
|
||||
|
||||
chown -R "$dest_owner" /dest
|
||||
'
|
||||
sh -c "
|
||||
cd /src && \
|
||||
find . -mindepth 1 -maxdepth 1 \
|
||||
! -name .git \
|
||||
-exec cp -r {} /dest/ \; && \
|
||||
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
|
||||
chown -R \"\$DEST_OWNER\" /dest
|
||||
"
|
||||
|
||||
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
|
||||
|
||||
@@ -173,18 +151,16 @@ jobs:
|
||||
|
||||
docker run --rm \
|
||||
-v "${DEPLOY_PATH}:/workspace/nexus" \
|
||||
-v "/tmp:/tmp-host:ro" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-w /workspace/nexus \
|
||||
-i \
|
||||
docker:cli \
|
||||
sh -c '
|
||||
set -eu
|
||||
umask 077
|
||||
cat > /tmp/nexus-rollback-env
|
||||
trap '\''rm -f /tmp/nexus-rollback-env'\'' EXIT INT TERM
|
||||
docker compose --env-file /tmp/nexus-rollback-env build --no-cache
|
||||
docker compose --env-file /tmp/nexus-rollback-env up -d --wait --force-recreate
|
||||
' < "${ENV_TMPFILE}"
|
||||
sh -c "
|
||||
set -e
|
||||
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
|
||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
|
||||
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
|
||||
"
|
||||
|
||||
echo "✅ Rollback redeploy completed"
|
||||
|
||||
@@ -210,14 +186,11 @@ jobs:
|
||||
WAIT=1
|
||||
while [ $RETRY -lt $MAX ]; do
|
||||
RETRY=$((RETRY + 1))
|
||||
HEALTH_BODY=$(curl -sf --max-time 10 https://nexus.noveria.net/health || true)
|
||||
case "$HEALTH_BODY" in
|
||||
'{"status":"Healthy"'*)
|
||||
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
|
||||
echo ""
|
||||
echo "✅ Health check passed (attempt $RETRY/$MAX)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
[ -n "$HEALTH_BODY" ] && echo "⚠️ Health endpoint is degraded: $HEALTH_BODY"
|
||||
fi
|
||||
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
|
||||
sleep $WAIT
|
||||
NEXT=$((WAIT + RETRY))
|
||||
|
||||
@@ -131,8 +131,7 @@ public sealed class StaleTaskRecoveryTests
|
||||
var recoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService,
|
||||
new FakeNotificationService());
|
||||
liveUpdateService);
|
||||
|
||||
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
|
||||
|
||||
@@ -144,51 +143,7 @@ public sealed class StaleTaskRecoveryTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
|
||||
{
|
||||
await using var fixture = await TaskWorkflowFixture.CreateAsync();
|
||||
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
|
||||
|
||||
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Stalled agent task",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = stalledTimestamp,
|
||||
CreatedAt = stalledTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
|
||||
{
|
||||
Title = "Fresh in progress",
|
||||
State = "In progress",
|
||||
Source = "iris",
|
||||
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
CreatedAt = stalledTimestamp
|
||||
}, CancellationToken.None);
|
||||
|
||||
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, flagged);
|
||||
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
|
||||
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
|
||||
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
|
||||
|
||||
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
|
||||
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
|
||||
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
|
||||
|
||||
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
|
||||
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
|
||||
TimeSpan.FromMinutes(40), CancellationToken.None);
|
||||
Assert.Equal(0, flaggedAgain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
|
||||
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var services = new ServiceCollection();
|
||||
@@ -199,21 +154,20 @@ public sealed class StaleTaskRecoveryTests
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StalledMinutes = 45,
|
||||
IntervalMinutes = 10
|
||||
StaleHours = 4,
|
||||
IntervalMinutes = 30
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
|
||||
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
|
||||
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
|
||||
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
|
||||
Assert.Equal(7, flaggedCount);
|
||||
Assert.Equal(1, fakeRecoveryService.CallCount);
|
||||
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
|
||||
Assert.Equal(7, resetCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
|
||||
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
|
||||
{
|
||||
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
|
||||
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -227,8 +181,8 @@ public sealed class StaleTaskRecoveryTests
|
||||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||||
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
|
||||
{
|
||||
StalledMinutes = 40,
|
||||
IntervalMinutes = 10
|
||||
StaleHours = 2,
|
||||
IntervalMinutes = 30
|
||||
}),
|
||||
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
|
||||
|
||||
@@ -237,8 +191,8 @@ public sealed class StaleTaskRecoveryTests
|
||||
await firstCall.Task.WaitAsync(cts.Token);
|
||||
await backgroundService.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(fakeRecoveryService.FlagCallCount >= 1);
|
||||
Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
|
||||
Assert.True(fakeRecoveryService.CallCount >= 1);
|
||||
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -371,52 +325,19 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService
|
||||
|
||||
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
|
||||
{
|
||||
public int FlagCallCount { get; private set; }
|
||||
public int ResetCallCount { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
public TimeSpan LastThreshold { get; private set; }
|
||||
public Action? OnCall { get; set; }
|
||||
|
||||
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||
{
|
||||
FlagCallCount++;
|
||||
LastThreshold = stalledThreshold;
|
||||
OnCall?.Invoke();
|
||||
return Task.FromResult(7);
|
||||
}
|
||||
|
||||
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
ResetCallCount++;
|
||||
CallCount++;
|
||||
LastThreshold = staleThreshold;
|
||||
OnCall?.Invoke();
|
||||
return Task.FromResult(7);
|
||||
}
|
||||
}
|
||||
|
||||
file sealed class FakeNotificationService : INotificationService
|
||||
{
|
||||
public List<Notification> Created { get; } = [];
|
||||
|
||||
public Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
|
||||
{
|
||||
var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId };
|
||||
Created.Add(notification);
|
||||
return Task.FromResult(notification);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
|
||||
|
||||
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
|
||||
|
||||
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||
|
||||
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
|
||||
|
||||
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
|
||||
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
|
||||
}
|
||||
|
||||
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
|
||||
{
|
||||
public T CurrentValue { get; private set; } = currentValue;
|
||||
|
||||
@@ -440,8 +440,7 @@ internal sealed class TaskWorkflowFixture : IAsyncDisposable
|
||||
var staleTaskRecoveryService = new StaleTaskRecoveryService(
|
||||
taskRepository,
|
||||
activityRepository,
|
||||
liveUpdateService,
|
||||
notificationService);
|
||||
liveUpdateService);
|
||||
|
||||
var taskService = new TaskService(
|
||||
taskRepository,
|
||||
|
||||
@@ -236,25 +236,15 @@ public class DashboardController(
|
||||
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
||||
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||
|
||||
// PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
|
||||
// Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
|
||||
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
|
||||
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
|
||||
// mit einer InvalidOperationException.
|
||||
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
|
||||
try
|
||||
{
|
||||
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 envelope = await readTask;
|
||||
readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||
|
||||
if (envelope.Type == "notifications.snapshot")
|
||||
{
|
||||
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||
@@ -273,24 +263,12 @@ public class DashboardController(
|
||||
envelope,
|
||||
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||
}
|
||||
else
|
||||
else if (await heartbeatTask)
|
||||
{
|
||||
var ticked = await heartbeatTask;
|
||||
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||
if (!ticked) break;
|
||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client hat die Verbindung beendet — normal.
|
||||
}
|
||||
catch (System.Threading.Channels.ChannelClosedException)
|
||||
{
|
||||
// Subscription serverseitig geschlossen — Stream regulär beenden.
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("tasks/{id:guid}/move")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> MoveTask(
|
||||
@@ -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>
|
||||
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||
@@ -399,7 +331,19 @@ public class DashboardController(
|
||||
|
||||
[HttpGet("tasks/{id:guid}/children")]
|
||||
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}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||
|
||||
@@ -99,8 +99,7 @@ public sealed record DashboardTaskDto(
|
||||
List<DashboardTaskDto>? ChildTasks = null,
|
||||
int ChildTaskCount = 0,
|
||||
int OpenChildTaskCount = 0,
|
||||
bool HasVisibleDelegation = false,
|
||||
int DoneChildTaskCount = 0
|
||||
bool HasVisibleDelegation = false
|
||||
);
|
||||
|
||||
public sealed record CreateDashboardTaskRequest(
|
||||
@@ -184,11 +183,6 @@ public sealed record PostActivityRequest(
|
||||
string? Type = null
|
||||
);
|
||||
|
||||
public sealed record RequestChangesRequest(
|
||||
string Comment,
|
||||
string? TargetState = null
|
||||
);
|
||||
|
||||
// ── Agent Workflow DTOs ──
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,9 +2,5 @@ namespace Nexus.Api.Services;
|
||||
|
||||
public interface IStaleTaskRecoveryService
|
||||
{
|
||||
/// <summary>Nicht-destruktiv: markiert hängende In-progress-Tasks und benachrichtigt Iris.</summary>
|
||||
Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Destruktiv (nur manuell): setzt hängende In-progress-Tasks hart auf Backlog.</summary>
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -33,12 +33,9 @@ public interface ITaskService
|
||||
// Task Board
|
||||
Task<BoardResponse> GetBoardAsync(CancellationToken ct = default);
|
||||
Task<TaskOperationResult> MoveTaskAsync(Guid id, string newState, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default);
|
||||
Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default);
|
||||
Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default);
|
||||
Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<WorkTask>> GetChildTasksAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default);
|
||||
Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default);
|
||||
Task<DashboardTaskDto?> GetDashboardTaskByIdAsync(Guid id, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ public sealed class StaleTaskRecoveryBackgroundService(
|
||||
{
|
||||
try
|
||||
{
|
||||
var flaggedCount = await RunWatchdogOnceAsync(stoppingToken);
|
||||
if (flaggedCount > 0)
|
||||
logger.LogInformation("Stall watchdog flagged {FlaggedCount} stalled task(s) for Iris.", flaggedCount);
|
||||
var resetCount = await RunRecoveryOnceAsync(stoppingToken);
|
||||
if (resetCount > 0)
|
||||
logger.LogInformation("Stale task recovery reset {ResetCount} task(s).", resetCount);
|
||||
}
|
||||
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();
|
||||
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";
|
||||
|
||||
/// <summary>Schwelle (Minuten) ohne Aktivität, ab der ein In-progress-Task als hängend gilt.</summary>
|
||||
public int StalledMinutes { get; set; } = 40;
|
||||
|
||||
/// <summary>Prüfintervall des Watchdogs.</summary>
|
||||
public int IntervalMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Nur für den manuellen Hard-Reset-Endpoint: Alter (Stunden) ab dem hart zurückgesetzt wird.</summary>
|
||||
public int StaleHours { get; set; } = 2;
|
||||
|
||||
public TimeSpan GetStalledThreshold() => TimeSpan.FromMinutes(Math.Max(1, StalledMinutes));
|
||||
public int IntervalMinutes { get; set; } = 30;
|
||||
|
||||
public TimeSpan GetStaleThreshold() => TimeSpan.FromHours(Math.Max(1, StaleHours));
|
||||
|
||||
|
||||
@@ -7,81 +7,8 @@ namespace Nexus.Api.Services;
|
||||
public sealed class StaleTaskRecoveryService(
|
||||
ITaskRepository taskRepository,
|
||||
IActivityRepository activityRepository,
|
||||
ILiveUpdateService liveUpdateService,
|
||||
INotificationService notificationService) : IStaleTaskRecoveryService
|
||||
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
|
||||
{
|
||||
private const string StalledActivityType = "stalled";
|
||||
|
||||
/// <summary>
|
||||
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
|
||||
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
|
||||
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
|
||||
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
|
||||
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
|
||||
/// </summary>
|
||||
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var threshold = now - stalledThreshold;
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
|
||||
var inProgress = allTasks
|
||||
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
if (inProgress.Count == 0)
|
||||
return 0;
|
||||
|
||||
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
|
||||
var activityByTask = activities
|
||||
.Where(a => a.TaskId.HasValue)
|
||||
.GroupBy(a => a.TaskId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
|
||||
|
||||
var flaggedCount = 0;
|
||||
|
||||
foreach (var task in inProgress)
|
||||
{
|
||||
activityByTask.TryGetValue(task.Id, out var taskActivity);
|
||||
var latest = taskActivity?.FirstOrDefault();
|
||||
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
|
||||
|
||||
if (lastProgressAt >= threshold)
|
||||
continue;
|
||||
|
||||
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
|
||||
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var silentFor = now - lastProgressAt;
|
||||
await activityRepository.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = StalledActivityType,
|
||||
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"task_stalled",
|
||||
$"Task hängt: {task.Title}",
|
||||
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
flaggedCount++;
|
||||
}
|
||||
|
||||
if (flaggedCount > 0)
|
||||
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
|
||||
|
||||
return flaggedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
|
||||
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
|
||||
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
|
||||
/// </summary>
|
||||
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
|
||||
{
|
||||
var threshold = DateTimeOffset.UtcNow - staleThreshold;
|
||||
@@ -153,8 +80,88 @@ public sealed class StaleTaskRecoveryService(
|
||||
private async Task<BoardResponse> BuildBoardSnapshotAsync(CancellationToken ct)
|
||||
{
|
||||
var allTasks = await taskRepository.GetAllAsync(ct);
|
||||
var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
|
||||
return TaskService.BuildMasterBoard(allTasks, activity);
|
||||
var taskIds = allTasks.Select(task => task.Id).ToList();
|
||||
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(
|
||||
@@ -183,9 +190,20 @@ public sealed class StaleTaskRecoveryService(
|
||||
}
|
||||
|
||||
private static string FormatDuration(TimeSpan duration)
|
||||
=> duration.ToString(@"dd\.hh\:mm\:ss");
|
||||
|
||||
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
|
||||
{
|
||||
if (duration.TotalHours >= 1)
|
||||
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
|
||||
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
|
||||
var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority));
|
||||
return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt);
|
||||
}
|
||||
|
||||
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(
|
||||
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(
|
||||
Guid taskId, CancellationToken ct = default)
|
||||
@@ -244,6 +250,13 @@ public sealed class TaskBridgeService(
|
||||
return AgentIdentityCatalog.NormalizeActorId(actorId, allowedActors);
|
||||
}
|
||||
|
||||
private static IEnumerable<DashboardTaskDto> FlattenBoard(BoardResponse board)
|
||||
=> board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done);
|
||||
|
||||
private static DashboardTaskDto MapToDto(WorkTask t) => new(
|
||||
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo,
|
||||
t.ParentTaskId, t.DueDate, t.CreatedAt, t.UpdatedAt,
|
||||
|
||||
@@ -422,19 +422,6 @@ public sealed class TaskService(
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return BuildMasterBoard(all, activity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
|
||||
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
|
||||
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
|
||||
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
|
||||
/// damit nichts unsichtbar wird.
|
||||
/// </summary>
|
||||
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
|
||||
{
|
||||
var ids = all.Select(t => t.Id).ToHashSet();
|
||||
|
||||
var offen = new List<DashboardTaskDto>();
|
||||
var inProgress = new List<DashboardTaskDto>();
|
||||
@@ -444,10 +431,7 @@ public sealed class TaskService(
|
||||
|
||||
foreach (var task in all)
|
||||
{
|
||||
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
|
||||
if (!isMaster) continue;
|
||||
|
||||
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
|
||||
var dto = MapToDtoWithChildren(task, all, activity);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
task.ExpectedFrom = null;
|
||||
return await UpdateTaskStatusInternalAsync(
|
||||
task,
|
||||
TaskStateHelper.ToStateString(TaskState.Done),
|
||||
caller,
|
||||
"review",
|
||||
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
|
||||
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
|
||||
?? TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
|
||||
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
target = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
|
||||
var trimmed = comment.Trim();
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "review_changes_requested",
|
||||
Message = $"Änderung angefordert von {caller}: {trimmed}",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
task.ExpectedFrom = "iris";
|
||||
var result = await UpdateTaskStatusInternalAsync(
|
||||
task, target, caller, "review",
|
||||
$"Review zurückgegeben von {caller} → {target}", ct);
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"task_changes_requested",
|
||||
$"Änderung angefordert: {task.Title}",
|
||||
trimmed,
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedHours = Math.Max(1, staleHours);
|
||||
@@ -595,46 +507,28 @@ public sealed class TaskService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
|
||||
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
|
||||
/// </summary>
|
||||
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return all.Where(t => t.ParentTaskId == parentId)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var all = await activityRepo.GetRecentAsync(100, ct);
|
||||
return all.Where(e => e.TaskId == taskId).ToList();
|
||||
}
|
||||
|
||||
private 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)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
|
||||
var childDtos = includeChildren
|
||||
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
||||
: null;
|
||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
|
||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var dto = MapToDtoWithActivity(task, activity, allTasks);
|
||||
return dto with
|
||||
{
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childTasks.Count,
|
||||
ChildTaskCount = childDtos.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,9 +23,8 @@
|
||||
"RefreshTokenExpirationDays": 7
|
||||
},
|
||||
"TaskRecovery": {
|
||||
"StalledMinutes": 40,
|
||||
"IntervalMinutes": 10,
|
||||
"StaleHours": 2
|
||||
"StaleHours": 2,
|
||||
"IntervalMinutes": 30
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
+3
-12
@@ -2,15 +2,6 @@ name: nexus
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
# WAL-Archivierung bleibt deaktiviert, bis ein verwaltetes Off-Server-Ziel
|
||||
# mit Retention und Restore-Test existiert. Ein lokales Endlosarchiv ist
|
||||
# kein Backup und kann bei Fehlern pg_wal ungebremst wachsen lassen.
|
||||
command:
|
||||
- postgres
|
||||
- -c
|
||||
- archive_mode=off
|
||||
- -c
|
||||
- archive_command=
|
||||
restart: always
|
||||
deploy:
|
||||
resources:
|
||||
@@ -59,7 +50,7 @@ services:
|
||||
Jwt__Audience: ${JWT_AUDIENCE:-nexus-web}
|
||||
Bootstrap__OwnerEmail: ${BOOTSTRAP_OWNER_EMAIL:?Set BOOTSTRAP_OWNER_EMAIL in .env}
|
||||
# Initial owner password is generated once at first seed and then lives only in the DB.
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||
Admin__ResetToken: ${Admin__ResetToken:-}
|
||||
@@ -68,7 +59,7 @@ services:
|
||||
- host.docker.internal:host-gateway
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
restart: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
|
||||
@@ -115,7 +106,7 @@ services:
|
||||
- "127.0.0.1:18880:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
condition: service_started
|
||||
restart: true
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
|
||||
|
||||
@@ -68,8 +68,8 @@ Ansatz. Das Backend fungiert bereits als sichere Schicht zwischen allen Akteuren
|
||||
│ │
|
||||
│ ALLE Gateway-Calls → Authorization: Bearer <Gateway-Password> │
|
||||
└──────────────┬──────────────────────────────┬────────────────────┘
|
||||
│ openclaw-gateway-bao:18789 │
|
||||
│ (internes Docker-DNS) │
|
||||
│ host.docker.internal:18789 │
|
||||
│ (Gateway loopback/lan) │
|
||||
▼ │
|
||||
┌──────────────────────────────┐ │
|
||||
│ OpenClaw Gateway Container │ │
|
||||
@@ -199,7 +199,7 @@ Ebene 4: X-Agent-Id Header (Agent-Identität für Task-State-Enforcement)
|
||||
```
|
||||
POST /api/v1/operations/snapshot
|
||||
→ DashboardService → OpenClawGatewayClient.InvokeToolAsync()
|
||||
→ POST http://openclaw-gateway-bao:18789/tools/invoke
|
||||
→ POST http://host.docker.internal:18789/tools/invoke
|
||||
Authorization: Bearer <Gateway-Password>
|
||||
```
|
||||
|
||||
@@ -220,7 +220,7 @@ POST /api/v1/operations/snapshot
|
||||
|
||||
### 5.2 Docker-Netzwerk & Gateway-Bind
|
||||
|
||||
**Aktueller Stand (2026-07-09):**
|
||||
**Aktuelles Problem:**
|
||||
```
|
||||
compose.yaml:
|
||||
api:
|
||||
@@ -228,27 +228,32 @@ compose.yaml:
|
||||
- host.docker.internal:host-gateway
|
||||
networks:
|
||||
- nexus
|
||||
- openclaw_default
|
||||
- openclaw_default ← API-Container ist im Gateway-Netzwerk
|
||||
|
||||
Gateway-Konfiguration:
|
||||
gateway.bind: "lan"
|
||||
|
||||
Nexus-Konfiguration:
|
||||
OPENCLAW_BASE_URL=http://openclaw-gateway-bao:18789
|
||||
gateway.bind: "loopback" ← Bindet nur 127.0.0.1 IM GATEWAY-CONTAINER
|
||||
```
|
||||
|
||||
**Ergebnis:**
|
||||
- Nexus erreicht das Gateway direkt über Docker-DNS im gemeinsamen `openclaw_default`-Netz.
|
||||
- Der Umweg über einen nicht veröffentlichten Host-Port entfällt.
|
||||
- Der produktive Aggregat-Healthcheck prüft neben PostgreSQL auch die Runtime-Verbindung.
|
||||
- `host.docker.internal:18789` funktioniert, weil `extra_hosts` auf den Docker-Host zeigt
|
||||
- ABER: Docker-Port-Forward (wenn vorhanden) sendet an Container-IP, nicht loopback
|
||||
- Die `openclaw_default` Netzwerk-Mitgliedschaft des API-Containers wird NICHT genutzt
|
||||
|
||||
Der frühere Pfad `host.docker.internal:18789` war auf dem VPS nicht erreichbar und ist obsolet.
|
||||
|
||||
Produktive Einstellung:
|
||||
```yaml
|
||||
Integrations__OpenClaw__BaseUrl: http://openclaw-gateway-bao:18789
|
||||
**Empfehlung (siehe gateway-api-research.md, Abschnitt 6):**
|
||||
```json5
|
||||
// openclaw.json
|
||||
{
|
||||
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
|
||||
|
||||
|
||||
@@ -290,30 +290,30 @@ The Nexus compose.yaml already includes the full integration infrastructure:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
extra_hosts:
|
||||
- host.docker.internal:host-gateway
|
||||
environment:
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://openclaw-gateway-bao:18789}
|
||||
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
|
||||
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
|
||||
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
|
||||
networks:
|
||||
- nexus
|
||||
- openclaw_default
|
||||
```
|
||||
|
||||
The API container:
|
||||
- Uses Docker DNS (`openclaw-gateway-bao:18789`) in the shared `openclaw_default` network
|
||||
- Does not depend on a published host port for the Gateway
|
||||
- Uses `host.docker.internal:18789` to reach the Gateway via the Docker host
|
||||
- Has `extra_hosts` configured for `host.docker.internal`
|
||||
- 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 |
|
||||
|----------|--------|-----|
|
||||
| `host.docker.internal:18789` | ❌ No | No reachable host listener on the VPS |
|
||||
| `openclaw-gateway-bao:18789` in `openclaw_default` | ✅ Yes | Direct container-to-container routing via Docker DNS |
|
||||
| Gateway with `--network host` | ✅ Yes | Process sees host's 127.0.0.1 directly |
|
||||
| Gateway with `-p 18789:18789` + loopback bind | ❌ No | Port forward sends to container IP, not loopback |
|
||||
| Gateway with `-p 18789:18789` + lan bind | ✅ Yes | Listens on all interfaces including container IP |
|
||||
|
||||
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
|
||||
{
|
||||
@@ -325,8 +325,8 @@ The Gateway must listen on its container interface (`gateway.bind: "lan"`):
|
||||
|
||||
**Test command (from Nexus API container):**
|
||||
```bash
|
||||
curl -s http://openclaw-gateway-bao:18789/
|
||||
# Expected: HTTP 200 from inside nexus-api-1
|
||||
curl -s http://host.docker.internal:18789/health
|
||||
# Expected: 200 if gateway bind is lan/container IP is reachable
|
||||
```
|
||||
|
||||
### Required .env Vars for Nexus
|
||||
|
||||
@@ -5,15 +5,6 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Kompression für Bundle + API-JSON (Board-Payload ~100 KB → wenige KB).
|
||||
# text/event-stream bewusst NICHT in gzip_types: gzip würde den SSE-Stream puffern.
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_types application/json application/javascript text/css text/javascript image/svg+xml;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
|
||||
+145
-7
@@ -1,14 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* App — nur noch Router-Einstieg + Toasts.
|
||||
* Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
|
||||
* und umschließt alle Seiten außer dem Login.
|
||||
*/
|
||||
import { RouterView } from 'vue-router'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { Activity } from '@lucide/vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useOperationsStore } from './stores/operations'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
||||
import AppHeader from './components/layout/AppHeader.vue'
|
||||
import ModuleView from './components/ModuleView.vue'
|
||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||
|
||||
const store = useOperationsStore()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeView = computed(() => {
|
||||
if (route.name === 'Settings') return 'Settings'
|
||||
if (route.name === 'ProjectDetail') return 'ProjectDetail'
|
||||
return String(route.name ?? 'Dashboard')
|
||||
})
|
||||
|
||||
const routePaths: Record<string, string> = {
|
||||
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
|
||||
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
|
||||
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
|
||||
}
|
||||
|
||||
const navigate = (label: string) => {
|
||||
mobileNavOpen.value = false
|
||||
return router.push(routePaths[label] ?? '/dashboard')
|
||||
}
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
const standaloneViews = computed(() => {
|
||||
if (route.name === 'Dashboard') return true
|
||||
if (route.meta?.standalone) return true
|
||||
return false
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated) store.refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
|
||||
<div v-else class="shell">
|
||||
<AppSidebar
|
||||
:active-view="activeView"
|
||||
:mobile-nav-open="mobileNavOpen"
|
||||
:queued-tasks="store.snapshot.metrics.queuedTasks"
|
||||
:incidents="store.snapshot.metrics.incidents"
|
||||
@navigate="navigate"
|
||||
/>
|
||||
|
||||
<main>
|
||||
<AppHeader
|
||||
:connected="store.connected"
|
||||
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
|
||||
/>
|
||||
|
||||
<section class="content">
|
||||
<RouterView v-if="standaloneViews" />
|
||||
|
||||
<template v-else>
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<span class="eyebrow">MISSION CONTROL</span>
|
||||
<h1>{{ activeView }}</h1>
|
||||
<p>System overview and operational intelligence across Noveria.</p>
|
||||
</div>
|
||||
<button class="refresh" @click="store.refresh()">
|
||||
<Activity :size="15" :class="{ spin: store.loading }" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ModuleView
|
||||
:view="activeView"
|
||||
:snapshot="store.snapshot"
|
||||
:routing="store.routing"
|
||||
@create-project="store.createProject"
|
||||
@create-task="store.createTask"
|
||||
@update-task-state="store.updateTaskState"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</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 {
|
||||
background:
|
||||
radial-gradient(1100px 700px at 12% -10%, rgba(79, 124, 255, 0.10), transparent 60%),
|
||||
radial-gradient(1000px 700px at 95% 8%, rgba(181, 87, 246, 0.09), transparent 60%),
|
||||
var(--space-0, #050410);
|
||||
color: var(--tx, #ece9ff);
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', sans-serif;
|
||||
margin: 0;
|
||||
@@ -95,10 +92,6 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1, h2, h3, .font-display {
|
||||
font-family: 'Space Grotesk', 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
/* Nexus overrides for existing CSS variables used in dashboard */
|
||||
:root {
|
||||
--nx-bg: #080a0f;
|
||||
@@ -133,9 +126,9 @@ h1, h2, h3, .font-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 22px 14px 14px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
||||
backdrop-filter: blur(14px);
|
||||
border-right: 1px solid #1a1e27;
|
||||
background: rgba(9, 11, 16, 0.94);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -150,16 +143,15 @@ h1, h2, h3, .font-display {
|
||||
height: 35px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow-purple);
|
||||
border: 1px solid #443d7c;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, #241f44, #12121f);
|
||||
color: #b8adff;
|
||||
box-shadow: 0 0 24px rgba(139, 124, 246, 0.13);
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
display: block;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
@@ -186,38 +178,36 @@ h1, h2, h3, .font-display {
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #8991a1;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav button:hover {
|
||||
color: var(--tx);
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
.nav button:hover,
|
||||
.nav button.active {
|
||||
color: #ececf5;
|
||||
background: var(--nx-accent-soft);
|
||||
}
|
||||
|
||||
.nav button.active {
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, rgba(124, 108, 255, 0.22), rgba(124, 108, 255, 0.04));
|
||||
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, 0.25);
|
||||
box-shadow: inset 2px 0 var(--nx-accent);
|
||||
}
|
||||
|
||||
.nav button i {
|
||||
margin-left: auto;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid #343947;
|
||||
border-radius: 8px;
|
||||
background: rgba(124, 108, 255, 0.10);
|
||||
font-size: 9px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: 1px solid #1b1f28;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
@@ -250,12 +240,10 @@ h1, h2, h3, .font-display {
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--grad-soft);
|
||||
border: 1px solid var(--line-2);
|
||||
color: var(--tx);
|
||||
border-radius: 50%;
|
||||
background: #28243f;
|
||||
color: #bcb3ff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -268,9 +256,9 @@ main {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 30px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(8, 6, 20, 0.5);
|
||||
backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid #191d25;
|
||||
background: rgba(8, 10, 15, 0.68);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.search {
|
||||
@@ -278,20 +266,19 @@ main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
background: rgba(124, 108, 255, 0.06);
|
||||
color: var(--tx-3);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #202530;
|
||||
border-radius: 7px;
|
||||
color: #6f7889;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search kbd {
|
||||
margin-left: auto;
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid #2c313d;
|
||||
border-radius: 4px;
|
||||
color: var(--tx-3);
|
||||
color: #606979;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -306,20 +293,15 @@ main {
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--tx-2);
|
||||
border: 1px solid var(--line-2);
|
||||
background: rgba(124, 108, 255, 0.07);
|
||||
border-radius: 20px;
|
||||
padding: 4px 11px;
|
||||
color: #8c95a5;
|
||||
}
|
||||
|
||||
.connection.live {
|
||||
color: var(--st-work);
|
||||
color: var(--nx-green);
|
||||
}
|
||||
|
||||
.connection.preview {
|
||||
color: var(--st-queue);
|
||||
color: #e6b75d;
|
||||
}
|
||||
|
||||
.ask,
|
||||
@@ -327,20 +309,13 @@ main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 13px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--glow-purple);
|
||||
padding: 8px 11px;
|
||||
border: 1px solid #37315e;
|
||||
border-radius: 7px;
|
||||
background: #18152a;
|
||||
color: #c4bbff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
transition: filter .16s;
|
||||
}
|
||||
|
||||
.ask:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -356,7 +331,7 @@ main {
|
||||
|
||||
.eyebrow,
|
||||
.kicker {
|
||||
color: var(--a-mid);
|
||||
color: #7065c8;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
@@ -376,10 +351,9 @@ h1 {
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
background: rgba(124, 108, 255, 0.07);
|
||||
border: 1px solid var(--line-2);
|
||||
box-shadow: none;
|
||||
color: var(--tx-2);
|
||||
border-color: var(--nx-line);
|
||||
background: var(--nx-panel);
|
||||
color: #a5adba;
|
||||
}
|
||||
|
||||
.spin {
|
||||
@@ -396,7 +370,7 @@ h1 {
|
||||
display: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #aaa4e7;
|
||||
}
|
||||
|
||||
/* ── Keep existing module/layout styles for non-dashboard pages ── */
|
||||
@@ -409,10 +383,9 @@ h1 {
|
||||
|
||||
.metrics article,
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--glass);
|
||||
border-radius: var(--r);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--nx-line);
|
||||
background: linear-gradient(145deg, rgba(18, 21, 29, 0.96), rgba(12, 15, 21, 0.96));
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.metrics article {
|
||||
@@ -420,7 +393,7 @@ h1 {
|
||||
}
|
||||
|
||||
.metrics span {
|
||||
color: var(--tx-3);
|
||||
color: #717a8a;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
@@ -434,12 +407,12 @@ h1 {
|
||||
}
|
||||
|
||||
.metrics small {
|
||||
color: var(--tx-3);
|
||||
color: #687181;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.metrics small.up {
|
||||
color: var(--st-work);
|
||||
color: #55c995;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
@@ -462,7 +435,7 @@ h1 {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px solid #1d222c;
|
||||
}
|
||||
|
||||
.panel-head h2 {
|
||||
@@ -473,7 +446,7 @@ h1 {
|
||||
.panel-head button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #8e96a5;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -484,18 +457,18 @@ h1 {
|
||||
}
|
||||
|
||||
.badge.positive {
|
||||
color: var(--st-work);
|
||||
background: rgba(61, 220, 151, 0.12);
|
||||
color: var(--nx-green);
|
||||
background: rgba(81, 212, 154, 0.1);
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
color: var(--st-queue);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: #e7b660;
|
||||
background: rgba(231, 182, 96, 0.1);
|
||||
}
|
||||
|
||||
.badge.negative {
|
||||
color: var(--st-block);
|
||||
background: rgba(251, 113, 133, 0.12);
|
||||
color: #e16e75;
|
||||
background: rgba(225, 110, 117, 0.1);
|
||||
}
|
||||
|
||||
.runtime-row {
|
||||
@@ -510,10 +483,9 @@ h1 {
|
||||
height: 45px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--a-mid);
|
||||
background: var(--grad-soft);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 9px;
|
||||
color: #ad9fff;
|
||||
background: var(--nx-accent-soft);
|
||||
}
|
||||
|
||||
.runtime-main strong,
|
||||
@@ -545,7 +517,7 @@ h1 {
|
||||
width: 3px;
|
||||
min-height: 5px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(var(--a-mid), rgba(124, 108, 255, 0.35));
|
||||
background: linear-gradient(#927fff, #443b7c);
|
||||
}
|
||||
|
||||
.model {
|
||||
@@ -554,11 +526,11 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px solid #1b2029;
|
||||
}
|
||||
|
||||
.model > span:last-child {
|
||||
color: var(--tx-3);
|
||||
color: #687181;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
@@ -566,16 +538,16 @@ h1 {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--st-idle);
|
||||
background: #657083;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--st-work);
|
||||
box-shadow: 0 0 7px rgba(61, 220, 151, 0.4);
|
||||
background: var(--nx-green);
|
||||
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background: var(--st-block);
|
||||
background: #e16e75;
|
||||
}
|
||||
|
||||
.project {
|
||||
@@ -584,7 +556,7 @@ h1 {
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px solid #1b2029;
|
||||
}
|
||||
|
||||
.project-letter {
|
||||
@@ -592,9 +564,9 @@ h1 {
|
||||
height: 31px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid #353047;
|
||||
border-radius: 7px;
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
@@ -609,7 +581,7 @@ h1 {
|
||||
}
|
||||
|
||||
.project b {
|
||||
color: var(--tx-2);
|
||||
color: #838c9c;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -618,14 +590,14 @@ h1 {
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: var(--space-3);
|
||||
background: #242936;
|
||||
}
|
||||
|
||||
.progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--grad);
|
||||
background: linear-gradient(90deg, #685ac8, #a091ff);
|
||||
}
|
||||
|
||||
.event {
|
||||
@@ -633,7 +605,7 @@ h1 {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
border-bottom: 1px solid #1b2029;
|
||||
}
|
||||
|
||||
.event > span {
|
||||
@@ -641,19 +613,19 @@ h1 {
|
||||
height: 6px;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--st-idle);
|
||||
background: #657083;
|
||||
}
|
||||
|
||||
.event > span.runtime {
|
||||
background: var(--st-work);
|
||||
background: var(--nx-green);
|
||||
}
|
||||
|
||||
.event > span.deploy {
|
||||
background: var(--a-mid);
|
||||
background: #8b7cf6;
|
||||
}
|
||||
|
||||
.event > span.security {
|
||||
background: var(--st-queue);
|
||||
background: #e5ad52;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
@@ -667,7 +639,7 @@ h1 {
|
||||
|
||||
.placeholder svg {
|
||||
margin-bottom: 18px;
|
||||
color: var(--a-mid);
|
||||
color: #8074d8;
|
||||
}
|
||||
|
||||
.placeholder h2 {
|
||||
|
||||
@@ -37,14 +37,6 @@
|
||||
--st-block: #fb7185;
|
||||
--st-idle: #6b6796;
|
||||
|
||||
/* ── Agent/Role Semantic Colors ───────────────────── */
|
||||
--clr-iris: #c084fc;
|
||||
--clr-bao: #60a5fa;
|
||||
--clr-agent: #6ee7b7;
|
||||
--clr-review: #fdba74;
|
||||
--clr-stale: #fda4af;
|
||||
--clr-other: #fb923c;
|
||||
|
||||
/* ── Glows ────────────────────────────────────────── */
|
||||
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
|
||||
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
|
||||
@@ -61,38 +53,6 @@
|
||||
--sidebar-w: 248px;
|
||||
--topbar-h: 62px;
|
||||
--rail-w: 360px;
|
||||
|
||||
/* ── Legacy-Aliasse (v1-Views) → V2-Tokens ────────────
|
||||
Ältere Views konsumieren noch die v1-Variablennamen.
|
||||
Nicht anfassen: --border/--accent (shadcn-HSL-Tripel,
|
||||
werden als hsl(var(--…)) konsumiert). */
|
||||
--nx-bg: var(--space-1);
|
||||
--nx-panel: var(--glass);
|
||||
--nx-panel-soft: var(--glass-2);
|
||||
--nx-line: var(--line);
|
||||
--nx-muted: var(--tx-3);
|
||||
--nx-accent: var(--a-mid);
|
||||
--nx-accent-soft: rgba(124, 108, 255, 0.10);
|
||||
--nx-green: var(--st-work);
|
||||
--nx-text: var(--tx);
|
||||
--nx-text-dim: var(--tx-2);
|
||||
--panel: var(--glass);
|
||||
--card-color: var(--glass);
|
||||
--surface: var(--space-2);
|
||||
--surface-raised: var(--space-3);
|
||||
--accent-soft: rgba(124, 108, 255, 0.10);
|
||||
--accent-secondary: var(--a-purple);
|
||||
--text-primary: var(--tx);
|
||||
--text-secondary: var(--tx-2);
|
||||
--text-muted: var(--tx-3);
|
||||
--text-dim: var(--tx-3);
|
||||
|
||||
/* 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 ────────────────────────────── */
|
||||
@@ -148,53 +108,3 @@
|
||||
/* ── Typography helpers ────────────────────────────── */
|
||||
.font-display { font-family: 'Space Grotesk', sans-serif; }
|
||||
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Button Variants (nexus-token-basiert) ──────────── */
|
||||
.nexus-btn-gradient {
|
||||
border: none;
|
||||
background: var(--grad);
|
||||
color: #fff;
|
||||
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: #c084fc; }
|
||||
.ball.is-bao { background: rgba(59,130,246,.16); color: #60a5fa; }
|
||||
.ball.is-agent { background: rgba(16,185,129,.14); color: #6ee7b7; }
|
||||
.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: #c084fc; }
|
||||
.child-agent.is-bao { background: rgba(59,130,246,.14); color: #60a5fa; }
|
||||
.child-agent.is-agent { background: rgba(16,185,129,.12); color: #6ee7b7; }
|
||||
.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: #fdba74; }
|
||||
.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: #fdba74; }
|
||||
.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 {
|
||||
background: var(--grad);
|
||||
color: var(--tx);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
@@ -328,12 +328,12 @@ function avatarLabel() {
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge-blue { background:rgba(79,124,255,.14); color:var(--a-blue); border-color:rgba(79,124,255,.3); }
|
||||
.badge-purple { background:rgba(181,87,246,.14); color:var(--a-purple); border-color:rgba(181,87,246,.3); }
|
||||
.badge-amber { background:rgba(251,191,36,.13); color:var(--st-queue); border-color:rgba(251,191,36,.3); }
|
||||
.badge-green { background:rgba(61,220,151,.13); color:var(--st-work); border-color:rgba(61,220,151,.3); }
|
||||
.badge-cyan { background:rgba(52,214,245,.13); color:var(--st-think); border-color:rgba(52,214,245,.3); }
|
||||
.badge-rose { background:rgba(251,113,133,.13); color:var(--st-block); border-color:rgba(251,113,133,.3); }
|
||||
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
|
||||
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
|
||||
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
|
||||
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
|
||||
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
|
||||
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
|
||||
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
|
||||
|
||||
.m-pill {
|
||||
@@ -428,7 +428,7 @@ function avatarLabel() {
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ function avatarLabel() {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--st-think);
|
||||
color: #9fe8fb;
|
||||
min-height: 72px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -593,7 +593,7 @@ function avatarLabel() {
|
||||
.m-model-btn.active {
|
||||
background: var(--grad);
|
||||
border: none;
|
||||
color: var(--tx);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ defineEmits<{
|
||||
|
||||
.nc-av.iris-av {
|
||||
background: var(--grad);
|
||||
color: var(--tx);
|
||||
color: #fff;
|
||||
box-shadow: var(--glow-purple);
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ defineEmits<{
|
||||
}
|
||||
|
||||
.node.is-work .nc-bar i {
|
||||
background: linear-gradient(90deg, var(--st-work), var(--st-work));
|
||||
background: linear-gradient(90deg, #2bb87f, #3ddc97);
|
||||
}
|
||||
|
||||
/* ── Meta ────────────────────────────────────── */
|
||||
|
||||
@@ -159,7 +159,7 @@ defineEmits<{
|
||||
border: 1px solid rgba(251,113,133,.3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--st-block);
|
||||
color: #fda4b0;
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
|
||||
@@ -86,7 +86,7 @@ function renderEdges() {
|
||||
|
||||
const edgeList = buildEdges(props.agents)
|
||||
|
||||
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="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 pulses = ''
|
||||
let idCounter = 0
|
||||
@@ -105,17 +105,17 @@ function renderEdges() {
|
||||
if (e.kind === 'flow' && live) {
|
||||
// Active flow: gradient stroke + animate pulse
|
||||
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
|
||||
paths += `<path d="${d}" fill="none" stroke="var(--st-work)" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
|
||||
pulses += `<circle r="3.4" fill="var(--st-work)"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
|
||||
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
|
||||
} else if (e.kind === 'flow') {
|
||||
// Inactive flow
|
||||
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
|
||||
pulses += `<circle r="2.8" fill="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 {
|
||||
// Orchestration (Iris → Agent)
|
||||
const targetAgent = props.agents.find(a => a.id === e.b)
|
||||
const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
|
||||
paths += `<path d="${d}" fill="none" stroke="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;
|
||||
background: var(--grad);
|
||||
border: none;
|
||||
color: var(--tx);
|
||||
color: #fff;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -123,7 +123,7 @@ watch(
|
||||
.iris-av :deep(svg) {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--tx);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.iris-name {
|
||||
@@ -196,7 +196,7 @@ watch(
|
||||
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 {
|
||||
display: flex;
|
||||
@@ -222,7 +222,7 @@ watch(
|
||||
|
||||
.bubble.me {
|
||||
background: var(--grad);
|
||||
color: var(--tx);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 5px;
|
||||
margin-left: auto;
|
||||
box-shadow: var(--glow-purple);
|
||||
@@ -313,6 +313,6 @@ watch(
|
||||
.send :deep(svg) {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: var(--tx);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,7 +12,7 @@ function prioLabel(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 {
|
||||
|
||||
@@ -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">
|
||||
/**
|
||||
* 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 { useRoute, useRouter } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { useNotificationStore } from '../../stores/notifications'
|
||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
||||
import { railNav, railFooterNav, svg } from '../../composables/icons'
|
||||
import { initials } from '../../utils/format'
|
||||
import { useAgentStore } from '../../stores/agents'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { navigation, icons } from '../../composables/icons'
|
||||
import type { NavGroupDef } from '../../composables/icons'
|
||||
|
||||
defineProps<{
|
||||
mobileOpen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
import NavGroup from './NavGroup.vue'
|
||||
import { initials } from '../../utils/format'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
const liveSync = useLiveSyncStore()
|
||||
const agentStore = useAgentStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
const ownerInitials = computed(() =>
|
||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||
)
|
||||
|
||||
function isActive(itemRoute?: string): boolean {
|
||||
if (!itemRoute) return false
|
||||
if (route.path === itemRoute) return true
|
||||
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
|
||||
return route.path.startsWith(itemRoute + '/')
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
function navigate(itemRoute?: string) {
|
||||
if (!itemRoute) return
|
||||
emit('close')
|
||||
router.push(itemRoute)
|
||||
}
|
||||
/**
|
||||
* Dynamische Nav-Item-Counts aus den Stores.
|
||||
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
|
||||
*/
|
||||
const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
||||
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
|
||||
return navigation.map(group => ({
|
||||
...group,
|
||||
items: group.items.map(item => {
|
||||
let dynamicCount: string | undefined
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
switch (item.label) {
|
||||
case 'Agenten':
|
||||
case 'Hosts · OpenClaw':
|
||||
dynamicCount = String(agentStore.agentList.length)
|
||||
break
|
||||
case 'Task Board':
|
||||
dynamicCount = String(taskStore.taskList.length)
|
||||
break
|
||||
case 'Kosten & Tokens':
|
||||
dynamicCount = agentStore.todayCost
|
||||
break
|
||||
case 'Docs & .md':
|
||||
dynamicCount = '0'
|
||||
break
|
||||
case 'Incidents':
|
||||
dynamicCount = '0'
|
||||
break
|
||||
}
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (liveSync.connected) return 'Live'
|
||||
if (liveSync.connecting) return 'Verbinde…'
|
||||
return 'Polling'
|
||||
return {
|
||||
...item,
|
||||
count: dynamicCount ?? item.count,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
||||
<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 -->
|
||||
<button class="rail-brand" @click="navigate('/dashboard')">
|
||||
<span class="brand-mark" v-html="svg('command')"></span>
|
||||
<span class="rail-label brand-label">NEXUS</span>
|
||||
</button>
|
||||
<div class="side-top">
|
||||
<div class="brand-mark" v-html="icons.command || ''"></div>
|
||||
<div>
|
||||
<div class="brand-name">NEXUS</div>
|
||||
<div class="brand-sub">Mission Control</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="rail-nav v2-scroll">
|
||||
<button
|
||||
v-for="item in railNav"
|
||||
:key="item.route"
|
||||
:class="['rail-item', { active: isActive(item.route) }]"
|
||||
:title="item.label"
|
||||
@click="navigate(item.route)"
|
||||
>
|
||||
<span class="rail-icon" v-html="svg(item.icon)"></span>
|
||||
<span
|
||||
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
|
||||
class="rail-dot"
|
||||
></span>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
|
||||
class="rail-count"
|
||||
>{{ notificationStore.unreadCount }}</span>
|
||||
</button>
|
||||
<nav class="nav">
|
||||
<NavGroup
|
||||
v-for="(group, idx) in dynamicNavigation"
|
||||
:key="idx"
|
||||
:label="group.group"
|
||||
:items="group.items"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="rail-foot">
|
||||
<div class="rail-item static" :title="statusLabel">
|
||||
<span class="rail-icon">
|
||||
<span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
|
||||
</span>
|
||||
<span class="rail-label dim">{{ statusLabel }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="item in railFooterNav"
|
||||
:key="item.route"
|
||||
:class="['rail-item', { active: isActive(item.route) }]"
|
||||
:title="item.label"
|
||||
@click="navigate(item.route)"
|
||||
>
|
||||
<span class="rail-icon" v-html="svg(item.icon)"></span>
|
||||
<span class="rail-label">{{ item.label }}</span>
|
||||
</button>
|
||||
|
||||
<div class="rail-owner">
|
||||
<span class="avatar">{{ ownerInitials }}</span>
|
||||
<span class="rail-label owner-label">
|
||||
<span class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</span>
|
||||
<span class="owner-role">{{ auth.user?.role ?? 'Owner' }}</span>
|
||||
</span>
|
||||
<button class="logout-btn rail-label" title="Abmelden" @click="logout" v-html="svg('logout')"></button>
|
||||
<div class="side-foot">
|
||||
<div class="avatar">{{ ownerInitials }}</div>
|
||||
<div class="owner-info">
|
||||
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
|
||||
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rail {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 68px;
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
flex: 0 0 248px;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
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);
|
||||
backdrop-filter: blur(14px);
|
||||
overflow: hidden;
|
||||
transition: width .18s ease, box-shadow .18s ease;
|
||||
z-index: 100;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.rail:hover {
|
||||
width: 232px;
|
||||
box-shadow: 24px 0 60px -30px rgba(0, 0, 0, .8);
|
||||
}
|
||||
|
||||
/* Labels: unsichtbar bis die Rail expandiert */
|
||||
.rail-label {
|
||||
opacity: 0;
|
||||
white-space: nowrap;
|
||||
transition: opacity .14s ease .04s;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.rail:hover .rail-label,
|
||||
.rail.open .rail-label {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Brand ── */
|
||||
.rail-brand {
|
||||
.side-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 15px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
gap: 11px;
|
||||
padding: 18px 18px 16px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 38px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--grad);
|
||||
box-shadow: var(--glow-purple);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand-mark :deep(svg) {
|
||||
@@ -183,154 +142,108 @@ const statusLabel = computed(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.brand-label {
|
||||
.brand-name {
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
letter-spacing: .14em;
|
||||
color: var(--tx);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Nav ── */
|
||||
.rail-nav {
|
||||
.brand-sub {
|
||||
font-size: 10.5px;
|
||||
color: var(--tx-3);
|
||||
letter-spacing: .05em;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px 12px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 6px 12px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rail-item {
|
||||
position: relative;
|
||||
.side-foot {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
height: 42px;
|
||||
padding: 0 13px;
|
||||
flex: 0 0 auto;
|
||||
gap: 10px;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.rail-item:not(.static):hover {
|
||||
background: rgba(124, 108, 255, .08);
|
||||
.sidebar-close:hover {
|
||||
background: rgba(124,108,255,.1);
|
||||
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 {
|
||||
.sidebar-close :deep(svg) {
|
||||
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-weight: 600;
|
||||
padding: 1px 8px;
|
||||
border-radius: 20px;
|
||||
background: rgba(251, 113, 133, .16);
|
||||
border: 1px solid rgba(251, 113, 133, .35);
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.rail-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 6px 12px 10px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.on {
|
||||
background: var(--st-work);
|
||||
animation: pulse-work 1.8s infinite;
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: var(--st-idle);
|
||||
}
|
||||
|
||||
.rail-label.dim {
|
||||
color: var(--tx-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rail-owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 7px 5px 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
border-radius: 10px;
|
||||
background: var(--grad-soft);
|
||||
border: 1px solid var(--line-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--tx);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.owner-label {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.owner-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.owner-name {
|
||||
@@ -345,41 +258,7 @@ const statusLabel = computed(() => {
|
||||
.owner-role {
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
margin-top: 1px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-3);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
color: var(--st-block);
|
||||
background: rgba(251, 113, 133, .1);
|
||||
}
|
||||
|
||||
.logout-btn :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ── Mobile: Drawer ── */
|
||||
@media (max-width: 767px) {
|
||||
.rail {
|
||||
position: fixed;
|
||||
width: 232px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform .22s ease;
|
||||
}
|
||||
|
||||
.rail.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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">
|
||||
/**
|
||||
* Badge — Nexus V2 Status- und Label-Badge
|
||||
*
|
||||
* Erweitert: zusätzliche nexus-token-basierte Varianten für
|
||||
* Agenten-Rollen, Status-Pills und Prioritäten.
|
||||
* Original shadcn-Varianten bleiben erhalten.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -15,25 +8,10 @@ const badgeVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Original shadcn
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
|
||||
// Nexus V2 — Token-basierte Varianten (Farben aus nexus-tokens.css)
|
||||
work: 'nexus-badge-work',
|
||||
think: 'nexus-badge-think',
|
||||
blocked: 'nexus-badge-blocked',
|
||||
queue: 'nexus-badge-queue',
|
||||
done: 'nexus-badge-done',
|
||||
review: 'nexus-badge-review',
|
||||
iris: 'nexus-badge-iris',
|
||||
bao: 'nexus-badge-bao',
|
||||
agent: 'nexus-badge-agent',
|
||||
priorityHigh: 'nexus-badge-prio-high',
|
||||
priorityMed: 'nexus-badge-prio-med',
|
||||
priorityLow: 'nexus-badge-prio-low',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -57,78 +35,3 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Nexus V2 Token-basierte Badge-Varianten */
|
||||
.nexus-badge-work {
|
||||
border-color: rgba(61, 220, 151, .25);
|
||||
background: rgba(34, 197, 94, .12);
|
||||
color: var(--st-work);
|
||||
}
|
||||
|
||||
.nexus-badge-think {
|
||||
border-color: rgba(52, 214, 245, .25);
|
||||
background: rgba(52, 214, 245, .1);
|
||||
color: var(--st-think);
|
||||
}
|
||||
|
||||
.nexus-badge-blocked {
|
||||
border-color: rgba(251, 113, 133, .25);
|
||||
background: rgba(244, 63, 94, .12);
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
.nexus-badge-queue {
|
||||
border-color: rgba(251, 191, 36, .25);
|
||||
background: rgba(251, 191, 36, .12);
|
||||
color: var(--st-queue);
|
||||
}
|
||||
|
||||
.nexus-badge-done {
|
||||
border-color: rgba(34, 197, 94, .25);
|
||||
background: rgba(34, 197, 94, .12);
|
||||
color: var(--st-work);
|
||||
}
|
||||
|
||||
.nexus-badge-review {
|
||||
border-color: rgba(249, 115, 22, .25);
|
||||
background: rgba(249, 115, 22, .12);
|
||||
color: var(--clr-review);
|
||||
}
|
||||
|
||||
.nexus-badge-iris {
|
||||
border-color: rgba(147, 51, 234, .25);
|
||||
background: rgba(147, 51, 234, .12);
|
||||
color: var(--clr-iris);
|
||||
}
|
||||
|
||||
.nexus-badge-bao {
|
||||
border-color: rgba(59, 130, 246, .25);
|
||||
background: rgba(59, 130, 246, .12);
|
||||
color: var(--clr-bao);
|
||||
}
|
||||
|
||||
.nexus-badge-agent {
|
||||
border-color: rgba(16, 185, 129, .2);
|
||||
background: rgba(16, 185, 129, .12);
|
||||
color: var(--clr-agent);
|
||||
}
|
||||
|
||||
.nexus-badge-prio-high {
|
||||
border-color: var(--st-block);
|
||||
background: transparent;
|
||||
color: var(--st-block);
|
||||
}
|
||||
|
||||
.nexus-badge-prio-med {
|
||||
border-color: var(--st-queue);
|
||||
background: transparent;
|
||||
color: var(--st-queue);
|
||||
}
|
||||
|
||||
.nexus-badge-prio-low {
|
||||
border-color: var(--a-blue);
|
||||
background: transparent;
|
||||
color: var(--a-blue);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Card — Nexus V2 glass-panel Karte
|
||||
*
|
||||
* Verwendet nexus-tokens.css als Single Source of Truth.
|
||||
* Hintergrund-kompatibel mit shadcn-Card-Props.
|
||||
* Wrapped content in .glass-panel styles aus tokens.css.
|
||||
*/
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Props {
|
||||
class?: HTMLAttributes['class']
|
||||
/** Variante: 'glass' (default) | 'raised' | 'subtle' */
|
||||
variant?: 'glass' | 'raised' | 'subtle'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'glass',
|
||||
})
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn(
|
||||
'rounded-xl border',
|
||||
{
|
||||
'glass-panel': variant === 'glass',
|
||||
'card-raised': variant === 'raised',
|
||||
'card-subtle': variant === 'subtle',
|
||||
},
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* glass-panel ist in nexus-tokens.css definiert */
|
||||
|
||||
.card-raised {
|
||||
background: var(--glass-2);
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: var(--r);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.card-subtle {
|
||||
background: rgba(255, 255, 255, .02);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,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>
|
||||
@@ -7,18 +7,18 @@ const { toasts, remove } = useToast()
|
||||
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
color: 'var(--st-work)',
|
||||
bg: 'rgba(61, 220, 151, 0.10)',
|
||||
color: '#22c55e',
|
||||
bg: 'rgba(34, 197, 94, 0.10)',
|
||||
},
|
||||
error: {
|
||||
icon: XCircle,
|
||||
color: 'var(--st-block)',
|
||||
bg: 'rgba(251, 113, 133, 0.10)',
|
||||
color: '#ef4444',
|
||||
bg: 'rgba(239, 68, 68, 0.10)',
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: 'var(--a-blue)',
|
||||
bg: 'rgba(79, 124, 255, 0.10)',
|
||||
color: '#3b82f6',
|
||||
bg: 'rgba(59, 130, 246, 0.10)',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -76,7 +76,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--toast-color) 12%, transparent);
|
||||
pointer-events: auto;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.15s;
|
||||
@@ -114,7 +114,7 @@ const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
|
||||
.toast-close:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Transition animations */
|
||||
|
||||
@@ -4,35 +4,25 @@ import { type VariantProps, cva } from 'class-variance-authority'
|
||||
export { default as Button } from './Button.vue'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-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: {
|
||||
variant: {
|
||||
// shadcn-original
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
|
||||
// Nexus V2 — Token-basierte Varianten
|
||||
/** CTA / primäre Aktion — Gradient */
|
||||
gradient: 'nexus-btn-gradient',
|
||||
/** Icon-Only — quadratischer Button ohne Text */
|
||||
icon: 'nexus-btn-icon',
|
||||
/** Ghost thin — minimal hover */
|
||||
ghostSubtle: 'nexus-btn-ghost-subtle',
|
||||
/** Danger/Blocker — rote Akzent-Aktion */
|
||||
danger: 'nexus-btn-danger',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
iconSm: 'h-7 w-7 rounded-md',
|
||||
pill: 'h-7 px-4 rounded-full text-xs',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -1,13 +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 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"/>`,
|
||||
plus: `<path d="M12 5v14M5 12h14"/>`,
|
||||
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
|
||||
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
|
||||
bell: `<path d="M18 9a6 6 0 1 0-12 0c0 6-2.5 7-2.5 7h17S18 15 18 9M10.3 20a2 2 0 0 0 3.4 0"/>`,
|
||||
calendar: `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>`,
|
||||
logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>`,
|
||||
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
|
||||
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
|
||||
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
|
||||
@@ -54,22 +50,40 @@ export interface NavGroupDef {
|
||||
}
|
||||
|
||||
/**
|
||||
* Rail-Navigation — EINE Quelle für alle Seiten (Dashboard + Rest).
|
||||
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
|
||||
* unten im Fußbereich (railFooterNav).
|
||||
* Navigation structure matching NEXUS.nav from agents.js
|
||||
*/
|
||||
export const railNav: NavItemDef[] = [
|
||||
{ icon: 'grid', label: 'Dashboard', route: '/dashboard' },
|
||||
export const navigation: NavGroupDef[] = [
|
||||
{
|
||||
group: 'Operations',
|
||||
items: [
|
||||
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
|
||||
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
||||
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
||||
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Knowledge',
|
||||
items: [
|
||||
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
||||
{ icon: 'doc', label: 'Docs', route: '/docs' },
|
||||
{ icon: 'calendar', label: 'Kalender', route: '/calendar' },
|
||||
{ icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
|
||||
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
export const railFooterNav: NavItemDef[] = [
|
||||
{ icon: 'gear', label: 'Einstellungen', route: '/settings' },
|
||||
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,79 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* NexusLayout — gemeinsame Shell für ALLE Seiten
|
||||
*
|
||||
* Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
|
||||
* Content bekommt die volle restliche Fläche:
|
||||
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
|
||||
* - alle anderen: scrollbarer Container mit Seiten-Padding
|
||||
*
|
||||
* Die Live-Verbindung (SSE) gehört der Shell — EINE Verbindung für die
|
||||
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
|
||||
* NexusLayout — V2 Dashboard Shell
|
||||
* Flex row, 100vh, overflow hidden.
|
||||
* Sidebar (248px) + Main (flex:1, flex-column)
|
||||
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
|
||||
*/
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { ref } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useDashboardStore } from '../stores/dashboard'
|
||||
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||
import Sidebar from '../components/layout/Sidebar.vue'
|
||||
import { svg } from '../composables/icons'
|
||||
import Topbar from '../components/layout/Topbar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const liveSync = useLiveSyncStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const dashboardStore = useDashboardStore()
|
||||
|
||||
const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
|
||||
|
||||
/* ── Mobile Drawer ─────────────────────────────── */
|
||||
/* ── Mobile Sidebar State ───────────────────────── */
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen.value = false
|
||||
}
|
||||
|
||||
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
|
||||
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
|
||||
liveSync.connect(liveUser.value)
|
||||
}
|
||||
}
|
||||
|
||||
function onOnline() {
|
||||
liveSync.reconnectNow()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
liveSync.connect(liveUser.value)
|
||||
notificationStore.startPolling()
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
window.addEventListener('online', onOnline)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
liveSync.disconnect()
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('online', onOnline)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nexus-layout">
|
||||
<GalaxyBackground />
|
||||
<Sidebar
|
||||
:mobile-open="mobileMenuOpen"
|
||||
@close="closeMobileMenu"
|
||||
/>
|
||||
|
||||
<div class="rail-slot">
|
||||
<Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Hamburger + Backdrop -->
|
||||
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
|
||||
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
|
||||
<!-- Mobile Backdrop -->
|
||||
<div
|
||||
v-if="mobileMenuOpen"
|
||||
class="mobile-backdrop"
|
||||
@click="closeMobileMenu"
|
||||
></div>
|
||||
|
||||
<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 />
|
||||
</div>
|
||||
</main>
|
||||
@@ -89,15 +59,6 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Platzhalter in der Flex-Reihe — die Rail selbst liegt absolut darüber
|
||||
und kann expandieren, ohne den Content zu verschieben. */
|
||||
.rail-slot {
|
||||
width: 68px;
|
||||
flex: 0 0 68px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.nexus-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -109,62 +70,19 @@ onUnmounted(() => {
|
||||
|
||||
.nexus-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nexus-content.full-bleed {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nexus-content.full-bleed > :deep(*) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.nexus-content.page-scroll {
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px 64px;
|
||||
}
|
||||
|
||||
.mobile-toggle,
|
||||
.mobile-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.rail-slot {
|
||||
width: 0;
|
||||
flex: 0 0 0;
|
||||
}
|
||||
|
||||
.nexus-main {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 90;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line-2);
|
||||
border-radius: 12px;
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(12px);
|
||||
color: var(--tx-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-toggle :deep(svg) {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.mobile-backdrop {
|
||||
display: block;
|
||||
position: fixed;
|
||||
@@ -172,9 +90,5 @@ onUnmounted(() => {
|
||||
z-index: 99;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.nexus-content.page-scroll {
|
||||
padding: 60px 16px 48px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+19
-15
@@ -19,27 +19,31 @@ const routes = [
|
||||
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
||||
{ 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,
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'Dashboard', component: FlowBoard, meta: { fullBleed: true } },
|
||||
{ path: 'agents', name: 'Agents', component: AgentsIndexView },
|
||||
{ path: 'agents/:id', name: 'AgentDetail', component: AgentDetailView },
|
||||
{ path: 'tasks', name: 'Task Board', component: TaskBoardView },
|
||||
{ path: 'tasks/:id', name: 'TaskDetail', component: TaskDetailView },
|
||||
{ path: 'memory', name: 'Memory', component: MemoryView },
|
||||
{ path: 'docs', name: 'Docs', component: DocsView },
|
||||
{ path: 'calendar', name: 'Calendar', component: CalendarView },
|
||||
{ path: 'notifications', name: 'Notifications', component: NotificationsView },
|
||||
{ path: 'incidents', name: 'Incidents', component: IncidentsView },
|
||||
{ path: 'security', name: 'Security', component: SecurityView },
|
||||
{ path: 'projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
||||
{ path: 'settings', name: 'Settings', component: SettingsView },
|
||||
{ path: '', name: 'Dashboard', component: FlowBoard },
|
||||
],
|
||||
},
|
||||
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
controller: null as AbortController | null,
|
||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
watchdogTimer: null as ReturnType<typeof setInterval> | null,
|
||||
mode: 'polling' as 'polling' | 'live',
|
||||
lastSequence: 0,
|
||||
reconnectAttempts: 0,
|
||||
forUser: 'bao',
|
||||
}),
|
||||
|
||||
getters: {
|
||||
@@ -75,9 +73,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
if (this.connecting || this.connected) return
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
this.forUser = forUser
|
||||
this.controller = new AbortController()
|
||||
this.startWatchdog()
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
@@ -117,51 +113,19 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||
|
||||
await stream.closed
|
||||
// Stream „sauber" beendet (Proxy-Timeout, Server-Neustart, Netzwechsel):
|
||||
// muss genauso wie ein Fehler behandelt werden — sonst bleibt connected=true
|
||||
// hängen, Polling ist gestoppt und die Seite erhält nie wieder Updates.
|
||||
} catch (error) {
|
||||
if (!this.controller?.signal.aborted) {
|
||||
console.warn('[liveSync] stream failed', error)
|
||||
this.error = 'Live updates unavailable'
|
||||
}
|
||||
} finally {
|
||||
this.connecting = false
|
||||
}
|
||||
|
||||
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)
|
||||
},
|
||||
|
||||
/** 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()
|
||||
} finally {
|
||||
this.connecting = false
|
||||
if (!this.controller?.signal.aborted && !this.connected) {
|
||||
this.mode = 'polling'
|
||||
}
|
||||
}, 15000)
|
||||
},
|
||||
|
||||
stopWatchdog() {
|
||||
if (this.watchdogTimer) {
|
||||
clearInterval(this.watchdogTimer)
|
||||
this.watchdogTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
@@ -184,7 +148,6 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
},
|
||||
|
||||
disconnect() {
|
||||
this.stopWatchdog()
|
||||
this.controller?.abort()
|
||||
this.controller = null
|
||||
this.connected = false
|
||||
@@ -198,9 +161,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
||||
|
||||
scheduleReconnect(forUser = 'bao') {
|
||||
if (this.reconnectTimer) return
|
||||
// Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
|
||||
// danach sanft hochstaffeln bis 30s.
|
||||
const delay = this.reconnectAttempts === 0 ? 1000 : Math.min(30000, 5000 * this.reconnectAttempts)
|
||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
||||
this.reconnectAttempts += 1
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
|
||||
@@ -30,7 +30,6 @@ export interface DashboardTaskDto {
|
||||
lastActivityMessage?: string | null
|
||||
lastActivityAt?: string | null
|
||||
childTasks?: DashboardTaskDto[] | null
|
||||
doneChildTaskCount?: number
|
||||
childTaskCount?: number
|
||||
openChildTaskCount?: number
|
||||
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 ─────────────────────────── */
|
||||
async createTask(data: { title: string; detail?: string | null; priority?: string; assignedTo?: string }) {
|
||||
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 ──────── */
|
||||
async fetchAgentOverview(staleHours = 2) {
|
||||
this.agentOverviewLoading = true
|
||||
|
||||
@@ -143,10 +143,10 @@ function formatModifiedAt(dateStr: string): string {
|
||||
|
||||
const statusColor = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'Online': return 'var(--st-work)'
|
||||
case 'Degraded': return 'var(--st-queue)'
|
||||
case 'Offline': return 'var(--st-block)'
|
||||
default: return 'var(--tx-3)'
|
||||
case 'Online': return '#51d49a'
|
||||
case 'Degraded': return '#e5b05e'
|
||||
case 'Offline': return '#e16e75'
|
||||
default: return '#7e8799'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,15 +612,15 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel);
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.back-link:hover {
|
||||
border-color: var(--line-3);
|
||||
color: var(--tx);
|
||||
border-color: #443d7c;
|
||||
color: #d8dbe3;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
@@ -629,11 +629,11 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 12px;
|
||||
}
|
||||
.status-message.error {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.status-message.compact {
|
||||
padding: 20px;
|
||||
@@ -660,15 +660,15 @@ onUnmounted(() => {
|
||||
place-items: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(139,124,246,.1);
|
||||
color: var(--a-mid);
|
||||
color: #8b7cf6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agent-avatar.iris { background: rgba(139,124,246,.15); color: var(--a-mid); }
|
||||
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: var(--a-blue); }
|
||||
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: var(--a-blue); }
|
||||
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: var(--st-queue); }
|
||||
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: var(--a-purple); }
|
||||
.agent-avatar.executor { background: rgba(77,246,212,.15); color: var(--st-think); }
|
||||
.agent-avatar.iris { background: rgba(139,124,246,.15); color: #8b7cf6; }
|
||||
.agent-avatar.programmer { background: rgba(77,140,246,.15); color: #4d8cf6; }
|
||||
.agent-avatar.architekt { background: rgba(77,168,246,.15); color: #4da8f6; }
|
||||
.agent-avatar.reviewer { background: rgba(246,168,77,.15); color: #f6a84d; }
|
||||
.agent-avatar.researcher { background: rgba(139,77,246,.15); color: #8b4df6; }
|
||||
.agent-avatar.executor { background: rgba(77,246,212,.15); color: #4df6d4; }
|
||||
|
||||
.agent-header-info {
|
||||
flex: 1;
|
||||
@@ -678,7 +678,7 @@ onUnmounted(() => {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--a-mid);
|
||||
color: var(--accent, #7b6ef2);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
@@ -686,7 +686,7 @@ onUnmounted(() => {
|
||||
margin: 0 0 4px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.agent-status-row {
|
||||
display: flex;
|
||||
@@ -702,18 +702,18 @@ onUnmounted(() => {
|
||||
}
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
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-sep { color: var(--line-2); font-size: 11px; }
|
||||
.status-sep { color: #3d4152; font-size: 11px; }
|
||||
|
||||
.thinking-section {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
@@ -731,11 +731,11 @@ onUnmounted(() => {
|
||||
font-size: 8.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .12em;
|
||||
color: var(--a-mid);
|
||||
color: var(--accent, #7b6ef2);
|
||||
}
|
||||
.section-head h2 {
|
||||
margin: 2px 0 0;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
@@ -744,7 +744,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.section-note {
|
||||
margin: 6px 0 0;
|
||||
color: var(--tx-3);
|
||||
color: #6f788b;
|
||||
font-size: 10px;
|
||||
line-height: 1.45;
|
||||
max-width: 560px;
|
||||
@@ -753,10 +753,10 @@ onUnmounted(() => {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--tx-3);
|
||||
background: #6b7385;
|
||||
}
|
||||
.live-dot.on {
|
||||
background: var(--st-work);
|
||||
background: #51d49a;
|
||||
}
|
||||
.icon-button {
|
||||
width: 30px;
|
||||
@@ -764,7 +764,7 @@ onUnmounted(() => {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: rgba(255,255,255,.03);
|
||||
color: var(--tx-2);
|
||||
color: #9ba3b5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
@@ -790,13 +790,13 @@ onUnmounted(() => {
|
||||
.summary-card small {
|
||||
display: block;
|
||||
margin-top: 7px;
|
||||
color: var(--tx-3);
|
||||
color: #6f788b;
|
||||
font-size: 9.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.summary-row span {
|
||||
display: block;
|
||||
color: var(--tx-3);
|
||||
color: #6f788b;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -804,7 +804,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.summary-row p {
|
||||
margin: 0;
|
||||
color: var(--tx-2);
|
||||
color: #cbd0dc;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -821,20 +821,20 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--tx-3);
|
||||
color: #6f788b;
|
||||
font-size: 10px;
|
||||
}
|
||||
.type-pill {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(123,110,242,.24);
|
||||
color: var(--a-mid);
|
||||
color: #aaa1ff;
|
||||
background: rgba(123,110,242,.08);
|
||||
font-size: 9px;
|
||||
}
|
||||
.thinking-item p {
|
||||
margin: 0;
|
||||
color: var(--tx-2);
|
||||
color: #cbd0dc;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -41,7 +41,7 @@ const fallbackAgents: AgentCard[] = [
|
||||
role: 'Chief of Staff',
|
||||
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
||||
tags: ['Orchestration', 'Delegation', 'Approval'],
|
||||
color: '#7c6cff',
|
||||
color: '#8b7cf6',
|
||||
icon: 'bot',
|
||||
},
|
||||
{
|
||||
@@ -50,7 +50,7 @@ const fallbackAgents: AgentCard[] = [
|
||||
role: 'Lead Developer',
|
||||
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
||||
tags: ['Coding', 'Development', 'Builds'],
|
||||
color: '#4f7cff',
|
||||
color: '#4d8cf6',
|
||||
icon: 'code',
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@ const fallbackAgents: AgentCard[] = [
|
||||
role: 'Infrastructure Engineer',
|
||||
description: 'Verantwortlich für Docker, Nginx, Deployment und VPS-Infrastruktur.',
|
||||
tags: ['Infrastructure', 'Deployment', 'Docker'],
|
||||
color: '#4f7cff',
|
||||
color: '#4da8f6',
|
||||
icon: 'server',
|
||||
},
|
||||
{
|
||||
@@ -68,7 +68,7 @@ const fallbackAgents: AgentCard[] = [
|
||||
role: 'Code QA',
|
||||
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
||||
tags: ['QA', 'Security', 'Code Review'],
|
||||
color: '#fbbf24',
|
||||
color: '#f6a84d',
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
@@ -77,7 +77,7 @@ const fallbackAgents: AgentCard[] = [
|
||||
role: 'Research Analyst',
|
||||
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
||||
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
||||
color: '#b557f6',
|
||||
color: '#8b4df6',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
@@ -86,7 +86,7 @@ const fallbackAgents: AgentCard[] = [
|
||||
role: 'Host Executor',
|
||||
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
||||
tags: ['Execution', 'Docker', 'VPS'],
|
||||
color: '#34d6f5',
|
||||
color: '#4df6d4',
|
||||
icon: 'terminal',
|
||||
},
|
||||
]
|
||||
@@ -161,7 +161,7 @@ function enrichAgent(item: any): AgentCard {
|
||||
role: item.role || fallback?.role || 'Agent',
|
||||
description: item.description || fallback?.description || 'OpenClaw agent',
|
||||
tags: item.tags?.length ? item.tags : fallback?.tags ?? [],
|
||||
color: fallback?.color ?? '#6f6aa0',
|
||||
color: fallback?.color ?? '#7e8799',
|
||||
icon: fallback?.icon ?? 'bot',
|
||||
model: item.model,
|
||||
statusLabel: item.statusLabel,
|
||||
@@ -327,19 +327,19 @@ onMounted(loadMissionControl)
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: rgba(139, 124, 246, 0.1);
|
||||
color: var(--a-mid);
|
||||
color: #8b7cf6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-text h1 {
|
||||
margin: 0 0 2px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.header-subtitle {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.gateway-chip {
|
||||
margin-left: auto;
|
||||
@@ -349,24 +349,24 @@ onMounted(loadMissionControl)
|
||||
padding: 6px 9px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
color: var(--tx-2);
|
||||
color: #9ba3b5;
|
||||
font-size: 10px;
|
||||
}
|
||||
.gateway-chip.ok {
|
||||
color: var(--st-work);
|
||||
color: #51d49a;
|
||||
border-color: rgba(81, 212, 154, .25);
|
||||
}
|
||||
.gateway-chip.warn {
|
||||
color: var(--st-queue);
|
||||
color: #e5b05e;
|
||||
border-color: rgba(229, 176, 94, .28);
|
||||
}
|
||||
.gateway-chip.error {
|
||||
color: var(--st-block);
|
||||
color: #f29b9b;
|
||||
border-color: rgba(242, 155, 155, .3);
|
||||
}
|
||||
.load-error {
|
||||
margin-bottom: 14px;
|
||||
color: var(--st-queue);
|
||||
color: #e5b05e;
|
||||
font-size: 11px;
|
||||
}
|
||||
.gateway-warning,
|
||||
@@ -376,19 +376,19 @@ onMounted(loadMissionControl)
|
||||
border-radius: 11px;
|
||||
border: 1px solid rgba(229, 176, 94, .24);
|
||||
background: rgba(229, 176, 94, .08);
|
||||
color: var(--st-queue);
|
||||
color: #f1d7aa;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.empty-state {
|
||||
border-color: var(--line);
|
||||
background: rgba(255,255,255,.03);
|
||||
color: var(--tx-2);
|
||||
color: #aab2c3;
|
||||
}
|
||||
.empty-state h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 14px;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
@@ -451,7 +451,7 @@ onMounted(loadMissionControl)
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -464,19 +464,19 @@ onMounted(loadMissionControl)
|
||||
margin: 0 0 1px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
.card-role {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 10.5px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 10px;
|
||||
flex: 1;
|
||||
@@ -486,7 +486,7 @@ onMounted(loadMissionControl)
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--tx-2);
|
||||
color: #8a92a5;
|
||||
font-size: 9.5px;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -494,19 +494,19 @@ onMounted(loadMissionControl)
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--tx-3);
|
||||
background: #6b7385;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.runtime-dot.on {
|
||||
background: var(--st-work);
|
||||
background: #51d49a;
|
||||
}
|
||||
.runtime-dot.connected { background: var(--st-work); }
|
||||
.runtime-dot.thinking { background: var(--a-blue); }
|
||||
.runtime-dot.blocked { background: var(--st-block); }
|
||||
.runtime-dot.stale { background: var(--st-queue); }
|
||||
.runtime-dot.error { background: var(--st-block); }
|
||||
.runtime-dot.unsupported { background: var(--st-queue); }
|
||||
.runtime-dot.ready { background: var(--tx-3); }
|
||||
.runtime-dot.connected { background: #51d49a; }
|
||||
.runtime-dot.thinking { background: #79aaff; }
|
||||
.runtime-dot.blocked { background: #f87171; }
|
||||
.runtime-dot.stale { background: #f59e0b; }
|
||||
.runtime-dot.error { background: #f29b9b; }
|
||||
.runtime-dot.unsupported { background: #e5b05e; }
|
||||
.runtime-dot.ready { background: #6b7385; }
|
||||
.runtime-model {
|
||||
margin-left: auto;
|
||||
max-width: 46%;
|
||||
@@ -514,12 +514,12 @@ onMounted(loadMissionControl)
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
color: var(--tx-3);
|
||||
color: #6f788b;
|
||||
}
|
||||
.runtime-detail {
|
||||
margin: 0 0 10px;
|
||||
min-height: 28px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -561,7 +561,7 @@ onMounted(loadMissionControl)
|
||||
border-top: 1px solid var(--line);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
transition: color 0.15s;
|
||||
|
||||
@@ -219,7 +219,7 @@ onMounted(() => {
|
||||
}
|
||||
.calendar-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -235,7 +235,7 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.calendar-status {
|
||||
display: flex;
|
||||
@@ -243,11 +243,11 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 11px;
|
||||
}
|
||||
.calendar-status.error {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
|
||||
/* Upcoming jobs */
|
||||
@@ -268,25 +268,25 @@ onMounted(() => {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
background: rgba(139,124,246,.1);
|
||||
}
|
||||
.upcoming-item-info strong {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.upcoming-item-schedule {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.upcoming-item-next {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -310,19 +310,19 @@ onMounted(() => {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.job-item-icon.status-completed {
|
||||
color: var(--st-work);
|
||||
color: #27ae60;
|
||||
background: rgba(39, 174, 96, 0.12);
|
||||
}
|
||||
.job-item-icon.status-running {
|
||||
color: var(--a-blue);
|
||||
color: #3498db;
|
||||
background: rgba(52, 152, 219, 0.12);
|
||||
}
|
||||
.job-item-icon.status-failed {
|
||||
color: var(--st-block);
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
}
|
||||
.job-item-icon.status-idle {
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
background: rgba(126, 135, 153, 0.12);
|
||||
}
|
||||
.job-item-info {
|
||||
@@ -332,19 +332,19 @@ onMounted(() => {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.job-item-id {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
margin-bottom: 2px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.job-item-schedule {
|
||||
display: block;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.job-item-meta {
|
||||
display: flex;
|
||||
@@ -355,7 +355,7 @@ onMounted(() => {
|
||||
}
|
||||
.job-item-meta small {
|
||||
font-size: 8px;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
text-align: right;
|
||||
}
|
||||
.job-status-badge {
|
||||
@@ -368,19 +368,19 @@ onMounted(() => {
|
||||
}
|
||||
.job-status-badge.status-completed {
|
||||
background: rgba(39, 174, 96, 0.15);
|
||||
color: var(--st-work);
|
||||
color: #27ae60;
|
||||
}
|
||||
.job-status-badge.status-running {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: var(--a-blue);
|
||||
color: #3498db;
|
||||
}
|
||||
.job-status-badge.status-failed {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: var(--st-block);
|
||||
color: #e74c3c;
|
||||
}
|
||||
.job-status-badge.status-idle {
|
||||
background: rgba(126, 135, 153, 0.15);
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAgentStore } from '../../stores/agents'
|
||||
import { useChatStore } from '../../stores/chat'
|
||||
import { useDashboardStore } from '../../stores/dashboard'
|
||||
import { useTaskStore } from '../../stores/tasks'
|
||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||
@@ -30,6 +31,7 @@ const agentStore = useAgentStore()
|
||||
const chatStore = useChatStore()
|
||||
const dashboardStore = useDashboardStore()
|
||||
const taskStore = useTaskStore()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
const router = useRouter()
|
||||
|
||||
const {
|
||||
@@ -68,6 +70,7 @@ onMounted(() => {
|
||||
dashboardStore.startPolling()
|
||||
taskStore.startPolling()
|
||||
taskStore.startBoardPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -76,6 +79,7 @@ onUnmounted(() => {
|
||||
dashboardStore.stopPolling()
|
||||
taskStore.stopPolling()
|
||||
taskStore.stopBoardPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -203,14 +203,14 @@ onMounted(loadDocs)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--tx-3);
|
||||
color: #6f7889;
|
||||
}
|
||||
.docs-search-bar input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -222,13 +222,13 @@ onMounted(loadDocs)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--tx-2);
|
||||
color: #8991a1;
|
||||
}
|
||||
.docs-filter-group select {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
@@ -241,7 +241,7 @@ onMounted(loadDocs)
|
||||
}
|
||||
.memory-sidebar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
max-height: 640px;
|
||||
@@ -250,7 +250,7 @@ onMounted(loadDocs)
|
||||
.memory-list-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--a-mid);
|
||||
color: #7065c8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 8px 6px;
|
||||
@@ -264,7 +264,7 @@ onMounted(loadDocs)
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
@@ -280,7 +280,7 @@ onMounted(loadDocs)
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
background: rgba(139,124,246,.1);
|
||||
}
|
||||
.memory-file-info strong {
|
||||
@@ -305,41 +305,41 @@ onMounted(loadDocs)
|
||||
}
|
||||
.doc-category-badge.cat-phases {
|
||||
background: rgba(139,124,246,.12);
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
}
|
||||
.doc-category-badge.cat-skills {
|
||||
background: rgba(81,212,154,.1);
|
||||
color: var(--st-work);
|
||||
color: #51d49a;
|
||||
}
|
||||
.doc-category-badge.cat-workspace {
|
||||
background: rgba(229,176,94,.1);
|
||||
color: var(--st-queue);
|
||||
color: #e5b05e;
|
||||
}
|
||||
.doc-category-badge.cat-nexus {
|
||||
background: rgba(109,159,230,.1);
|
||||
color: var(--a-blue);
|
||||
color: #6d9fe6;
|
||||
}
|
||||
.doc-category-badge.cat-nexus-phases {
|
||||
background: rgba(225,110,117,.1);
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.doc-type-tag {
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid #343947;
|
||||
border-radius: 4px;
|
||||
color: var(--tx-2);
|
||||
color: #8991a1;
|
||||
}
|
||||
.memory-file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.memory-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
min-height: 480px;
|
||||
@@ -363,7 +363,7 @@ onMounted(loadDocs)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.memory-back-btn {
|
||||
@@ -374,13 +374,13 @@ onMounted(loadDocs)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #8991a1;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-back-btn:hover {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.memory-status {
|
||||
@@ -389,11 +389,11 @@ onMounted(loadDocs)
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 11px;
|
||||
}
|
||||
.memory-status.error {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.memory-empty-state {
|
||||
display: flex;
|
||||
@@ -402,27 +402,27 @@ onMounted(loadDocs)
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 360px;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
}
|
||||
.memory-empty-state h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 14px;
|
||||
color: var(--tx-2);
|
||||
color: #a5adba;
|
||||
}
|
||||
.memory-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.memory-rendered {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--tx-2);
|
||||
color: #d0d4dd;
|
||||
}
|
||||
.memory-rendered :deep(h1),
|
||||
.memory-rendered :deep(h2),
|
||||
.memory-rendered :deep(h3) {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
||||
@@ -438,8 +438,8 @@ onMounted(loadDocs)
|
||||
.memory-rendered :deep(pre) {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--nx-line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.memory-rendered :deep(pre code) {
|
||||
@@ -447,7 +447,7 @@ onMounted(loadDocs)
|
||||
padding: 0;
|
||||
}
|
||||
.memory-rendered :deep(a) {
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
text-decoration: none;
|
||||
}
|
||||
.memory-rendered :deep(a:hover) {
|
||||
@@ -461,11 +461,11 @@ onMounted(loadDocs)
|
||||
}
|
||||
.memory-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: 1px solid var(--nx-line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.memory-rendered :deep(strong) {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -202,7 +202,7 @@ onMounted(loadIncidents)
|
||||
}
|
||||
.incident-sidebar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
max-height: 640px;
|
||||
@@ -211,7 +211,7 @@ onMounted(loadIncidents)
|
||||
.incident-list-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--a-mid);
|
||||
color: #7065c8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 8px 6px;
|
||||
@@ -225,7 +225,7 @@ onMounted(loadIncidents)
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
@@ -243,19 +243,19 @@ onMounted(loadIncidents)
|
||||
border-radius: 6px;
|
||||
}
|
||||
.incident-file-icon.sev-critical {
|
||||
color: var(--st-block);
|
||||
color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-major {
|
||||
color: var(--st-queue);
|
||||
color: #e67e22;
|
||||
background: rgba(230, 126, 34, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-minor {
|
||||
color: var(--st-queue);
|
||||
color: #f1c40f;
|
||||
background: rgba(241, 196, 15, 0.12);
|
||||
}
|
||||
.incident-file-icon.sev-unknown {
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
background: rgba(126, 135, 153, 0.12);
|
||||
}
|
||||
.incident-file-info strong {
|
||||
@@ -269,7 +269,7 @@ onMounted(loadIncidents)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.incident-file-excerpt {
|
||||
@@ -278,7 +278,7 @@ onMounted(loadIncidents)
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.severity-badge {
|
||||
@@ -291,23 +291,23 @@ onMounted(loadIncidents)
|
||||
}
|
||||
.severity-badge.sev-critical {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: var(--st-block);
|
||||
color: #e74c3c;
|
||||
}
|
||||
.severity-badge.sev-major {
|
||||
background: rgba(230, 126, 34, 0.15);
|
||||
color: var(--st-queue);
|
||||
color: #e67e22;
|
||||
}
|
||||
.severity-badge.sev-minor {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: var(--st-queue);
|
||||
color: #f1c40f;
|
||||
}
|
||||
.severity-badge.sev-unknown {
|
||||
background: rgba(126, 135, 153, 0.15);
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.incident-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
min-height: 480px;
|
||||
@@ -335,7 +335,7 @@ onMounted(loadIncidents)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.incident-back-btn {
|
||||
display: flex;
|
||||
@@ -345,13 +345,13 @@ onMounted(loadIncidents)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #8991a1;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.incident-back-btn:hover {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.incident-status {
|
||||
@@ -360,11 +360,11 @@ onMounted(loadIncidents)
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 11px;
|
||||
}
|
||||
.incident-status.error {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.incident-empty-state {
|
||||
display: flex;
|
||||
@@ -373,27 +373,27 @@ onMounted(loadIncidents)
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 360px;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
}
|
||||
.incident-empty-state h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 14px;
|
||||
color: var(--tx-2);
|
||||
color: #a5adba;
|
||||
}
|
||||
.incident-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.incident-rendered {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--tx-2);
|
||||
color: #d0d4dd;
|
||||
}
|
||||
.incident-rendered :deep(h1),
|
||||
.incident-rendered :deep(h2),
|
||||
.incident-rendered :deep(h3) {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
.incident-rendered :deep(h1) { font-size: 1.3rem; }
|
||||
@@ -409,8 +409,8 @@ onMounted(loadIncidents)
|
||||
.incident-rendered :deep(pre) {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--nx-line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.incident-rendered :deep(pre code) {
|
||||
@@ -418,7 +418,7 @@ onMounted(loadIncidents)
|
||||
padding: 0;
|
||||
}
|
||||
.incident-rendered :deep(a) {
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
text-decoration: none;
|
||||
}
|
||||
.incident-rendered :deep(a:hover) {
|
||||
@@ -432,11 +432,11 @@ onMounted(loadIncidents)
|
||||
}
|
||||
.incident-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: 1px solid var(--nx-line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.incident-rendered :deep(strong) {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -227,22 +227,22 @@ onMounted(loadMemories)
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
margin-bottom: 16px;
|
||||
color: var(--tx-3);
|
||||
color: #6f7889;
|
||||
}
|
||||
.memory-search-bar input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.memory-search-bar kbd {
|
||||
padding: 2px 5px;
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid #2c313d;
|
||||
border-radius: 4px;
|
||||
color: var(--tx-3);
|
||||
color: #606979;
|
||||
font-size: 9px;
|
||||
}
|
||||
.memory-layout {
|
||||
@@ -253,7 +253,7 @@ onMounted(loadMemories)
|
||||
}
|
||||
.memory-sidebar {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 8px;
|
||||
max-height: 640px;
|
||||
@@ -262,7 +262,7 @@ onMounted(loadMemories)
|
||||
.memory-list-header {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--a-mid);
|
||||
color: #7065c8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
padding: 10px 8px 6px;
|
||||
@@ -276,7 +276,7 @@ onMounted(loadMemories)
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
@@ -292,7 +292,7 @@ onMounted(loadMemories)
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
background: rgba(139,124,246,.1);
|
||||
}
|
||||
.memory-file-info strong {
|
||||
@@ -307,7 +307,7 @@ onMounted(loadMemories)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.memory-file-excerpt {
|
||||
@@ -318,7 +318,7 @@ onMounted(loadMemories)
|
||||
}
|
||||
.memory-content {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 24px;
|
||||
min-height: 480px;
|
||||
@@ -342,7 +342,7 @@ onMounted(loadMemories)
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.memory-back-btn {
|
||||
display: flex;
|
||||
@@ -352,13 +352,13 @@ onMounted(loadMemories)
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #8991a1;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.memory-back-btn:hover {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.memory-status {
|
||||
@@ -367,11 +367,11 @@ onMounted(loadMemories)
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 11px;
|
||||
}
|
||||
.memory-status.error {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.memory-empty-state {
|
||||
display: flex;
|
||||
@@ -380,27 +380,27 @@ onMounted(loadMemories)
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
min-height: 360px;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
}
|
||||
.memory-empty-state h3 {
|
||||
margin: 12px 0 6px;
|
||||
font-size: 14px;
|
||||
color: var(--tx-2);
|
||||
color: #a5adba;
|
||||
}
|
||||
.memory-empty-state p {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.memory-rendered {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: var(--tx-2);
|
||||
color: #d0d4dd;
|
||||
}
|
||||
.memory-rendered :deep(h1),
|
||||
.memory-rendered :deep(h2),
|
||||
.memory-rendered :deep(h3) {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
margin: 1.2em 0 0.5em;
|
||||
}
|
||||
.memory-rendered :deep(h1) { font-size: 1.3rem; }
|
||||
@@ -416,8 +416,8 @@ onMounted(loadMemories)
|
||||
.memory-rendered :deep(pre) {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--space-2);
|
||||
border: 1px solid var(--line);
|
||||
background: #0d1016;
|
||||
border: 1px solid var(--nx-line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.memory-rendered :deep(pre code) {
|
||||
@@ -425,7 +425,7 @@ onMounted(loadMemories)
|
||||
padding: 0;
|
||||
}
|
||||
.memory-rendered :deep(a) {
|
||||
color: var(--a-mid);
|
||||
color: #a99cf5;
|
||||
text-decoration: none;
|
||||
}
|
||||
.memory-rendered :deep(a:hover) {
|
||||
@@ -439,11 +439,11 @@ onMounted(loadMemories)
|
||||
}
|
||||
.memory-rendered :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--line);
|
||||
border-top: 1px solid var(--nx-line);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
.memory-rendered :deep(strong) {
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNotificationStore } from '../stores/notifications'
|
||||
import { useLiveSyncStore } from '../stores/liveSync'
|
||||
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
||||
|
||||
const store = useNotificationStore()
|
||||
const router = useRouter()
|
||||
const liveSyncStore = useLiveSyncStore()
|
||||
|
||||
const sortedNotifications = computed(() => {
|
||||
return [...store.notifications].sort(
|
||||
@@ -24,10 +26,10 @@ function typeIcon(type: string): string {
|
||||
|
||||
function typeColor(type: string): string {
|
||||
switch (type) {
|
||||
case 'task_assigned': return '#4f7cff'
|
||||
case 'task_review': return '#fbbf24'
|
||||
case 'task_blocked': return '#fb7185'
|
||||
default: return '#7c6cff'
|
||||
case 'task_assigned': return '#4d8cf6'
|
||||
case 'task_review': return '#f6a84d'
|
||||
case 'task_blocked': return '#e16e75'
|
||||
default: return '#7b6ef2'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,10 +55,12 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
|
||||
|
||||
onMounted(() => {
|
||||
store.startListPolling()
|
||||
liveSyncStore.connect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.stopListPolling()
|
||||
liveSyncStore.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -133,18 +137,18 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--nx-line, #1f2330);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--tx-3);
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
|
||||
.mark-all-btn:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: var(--tx);
|
||||
background: var(--nx-accent-soft, rgba(123, 110, 242, .08));
|
||||
color: #d8dbe3;
|
||||
}
|
||||
|
||||
/* ── Empty State ── */
|
||||
@@ -154,7 +158,7 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
color: var(--tx-3);
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -182,7 +186,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.notification-card:hover {
|
||||
background: rgba(124, 108, 255, 0.06);
|
||||
background: var(--nx-accent-soft, rgba(123, 110, 242, .06));
|
||||
}
|
||||
|
||||
.notification-card.unread {
|
||||
@@ -211,7 +215,7 @@ onUnmounted(() => {
|
||||
|
||||
.card-title {
|
||||
font-size: 12.5px;
|
||||
color: var(--tx);
|
||||
color: #d8dbe3;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -222,7 +226,7 @@ onUnmounted(() => {
|
||||
|
||||
.card-message {
|
||||
font-size: 10.5px;
|
||||
color: var(--tx-3);
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
margin-top: 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
@@ -238,12 +242,12 @@ onUnmounted(() => {
|
||||
|
||||
.timestamp {
|
||||
font-size: 9px;
|
||||
color: var(--tx-3);
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--tx-3);
|
||||
color: var(--nx-text-dim, #6f7889);
|
||||
opacity: .5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -221,7 +221,7 @@ onMounted(loadProject)
|
||||
}
|
||||
.project-detail-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@@ -234,7 +234,7 @@ onMounted(loadProject)
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--a-mid), var(--accent-secondary));
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
||||
color: #fff;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
@@ -273,7 +273,7 @@ onMounted(loadProject)
|
||||
}
|
||||
.btn-icon {
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
@@ -282,8 +282,8 @@ onMounted(loadProject)
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-icon:hover {
|
||||
background: rgba(124, 108, 255, 0.10);
|
||||
color: var(--a-mid);
|
||||
background: var(--nx-accent-soft);
|
||||
color: var(--nx-accent);
|
||||
}
|
||||
.btn-icon.btn-danger:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
@@ -294,7 +294,7 @@ onMounted(loadProject)
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
@@ -304,7 +304,7 @@ onMounted(loadProject)
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
@@ -321,7 +321,7 @@ onMounted(loadProject)
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--a-mid);
|
||||
background: var(--nx-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
@@ -335,7 +335,7 @@ onMounted(loadProject)
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
@@ -343,7 +343,7 @@ onMounted(loadProject)
|
||||
.progress-section {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--line-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.progress-header {
|
||||
display: flex;
|
||||
@@ -361,7 +361,7 @@ onMounted(loadProject)
|
||||
.progress-bar i {
|
||||
display: block;
|
||||
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;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
@@ -380,7 +380,7 @@ onMounted(loadProject)
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px dashed var(--line-2);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.task-list {
|
||||
@@ -394,7 +394,7 @@ onMounted(loadProject)
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.task-icon {
|
||||
@@ -403,7 +403,7 @@ onMounted(loadProject)
|
||||
.task-icon.done { color: rgb(34, 197, 94); }
|
||||
.task-icon.blocked { color: rgb(239, 68, 68); }
|
||||
.task-icon.backlog { color: var(--text-muted); }
|
||||
.task-icon.in-progress { color: var(--a-mid); }
|
||||
.task-icon.in-progress { color: var(--nx-accent); }
|
||||
.task-info {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -441,7 +441,7 @@ onMounted(loadProject)
|
||||
}
|
||||
.modal {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 420px;
|
||||
|
||||
@@ -192,7 +192,7 @@ onMounted(loadStatus)
|
||||
}
|
||||
.security-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
border-radius: 9px;
|
||||
background: var(--panel);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
@@ -204,27 +204,27 @@ onMounted(loadStatus)
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--r);
|
||||
color: var(--a-mid);
|
||||
border-radius: 9px;
|
||||
color: #a99cff;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.security-card-icon.twofa-icon {
|
||||
color: var(--st-queue);
|
||||
color: #e5b05e;
|
||||
background: rgba(229,176,94,.1);
|
||||
}
|
||||
.security-card-icon.passkey-icon {
|
||||
color: var(--a-blue);
|
||||
color: #6d9fe6;
|
||||
background: rgba(109,159,230,.1);
|
||||
}
|
||||
.security-card h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.security-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.policy-text {
|
||||
font-size: 11px;
|
||||
@@ -234,7 +234,7 @@ onMounted(loadStatus)
|
||||
.security-desc {
|
||||
margin: 0;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.security-detail-list {
|
||||
@@ -250,14 +250,14 @@ onMounted(loadStatus)
|
||||
}
|
||||
.security-detail-label {
|
||||
font-size: 10px;
|
||||
color: var(--tx-2);
|
||||
color: #8991a1;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.security-detail-value {
|
||||
font-size: 10px;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
@@ -281,11 +281,11 @@ code.security-detail-value {
|
||||
}
|
||||
.status-bool.enabled {
|
||||
background: rgba(81,212,154,.1);
|
||||
color: var(--st-work);
|
||||
color: #51d49a;
|
||||
}
|
||||
.status-bool.disabled {
|
||||
background: rgba(225,110,117,.08);
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.security-status-row {
|
||||
display: flex;
|
||||
@@ -293,16 +293,16 @@ code.security-detail-value {
|
||||
gap: 6px;
|
||||
}
|
||||
.check-icon {
|
||||
color: var(--st-work);
|
||||
color: #51d49a;
|
||||
}
|
||||
.x-icon {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.enabled-text {
|
||||
color: var(--st-work);
|
||||
color: #51d49a;
|
||||
}
|
||||
.disabled-text {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
|
||||
.memory-status {
|
||||
@@ -311,11 +311,11 @@ code.security-detail-value {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 48px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
font-size: 12px;
|
||||
}
|
||||
.memory-status.error {
|
||||
color: var(--st-block);
|
||||
color: #e16e75;
|
||||
}
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
|
||||
@@ -409,7 +409,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.grad-text {
|
||||
background: linear-gradient(135deg, var(--a-blue), var(--a-purple));
|
||||
background: linear-gradient(135deg, #4f7cff, #b557f6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -418,7 +418,7 @@ onMounted(() => {
|
||||
.settings-subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
@@ -444,7 +444,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid rgba(150, 140, 255, 0.08);
|
||||
}
|
||||
@@ -470,7 +470,7 @@ onMounted(() => {
|
||||
.field label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -490,7 +490,7 @@ onMounted(() => {
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 11px;
|
||||
background: rgba(10, 9, 24, 0.55);
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
font-size: 13.5px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
outline: none;
|
||||
@@ -513,13 +513,13 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.galaxy-select option {
|
||||
background: var(--space-3);
|
||||
color: var(--tx);
|
||||
background: #141130;
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.value-tag {
|
||||
font-size: 14px;
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -538,7 +538,7 @@ onMounted(() => {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background: linear-gradient(135deg, rgba(79, 124, 255, 0.15), rgba(181, 87, 246, 0.15));
|
||||
color: var(--a-mid);
|
||||
color: #b8adff;
|
||||
border: 1px solid rgba(124, 108, 255, 0.15);
|
||||
width: fit-content;
|
||||
}
|
||||
@@ -589,14 +589,14 @@ onMounted(() => {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.pw-toggle:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
}
|
||||
|
||||
/* ── Messages ──────────────────────────────────── */
|
||||
@@ -613,13 +613,13 @@ onMounted(() => {
|
||||
.msg.error {
|
||||
background: rgba(244, 63, 94, 0.1);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
color: var(--st-block);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
.msg.success {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
color: var(--st-work);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
/* ── Buttons ──────────────────────────────────── */
|
||||
@@ -630,7 +630,7 @@ onMounted(() => {
|
||||
padding: 9px 16px;
|
||||
border: none;
|
||||
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;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
@@ -665,7 +665,7 @@ onMounted(() => {
|
||||
font-family: 'Manrope', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
@@ -673,7 +673,7 @@ onMounted(() => {
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
/* ── Admin User Management ───────────────────── */
|
||||
@@ -711,7 +711,7 @@ onMounted(() => {
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, rgba(79, 124, 255, 0.2), rgba(181, 87, 246, 0.2));
|
||||
border: 1px solid rgba(124, 108, 255, 0.15);
|
||||
color: var(--a-mid);
|
||||
color: #b8adff;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
@@ -725,12 +725,12 @@ onMounted(() => {
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 11px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
@@ -754,31 +754,31 @@ onMounted(() => {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: rgba(124, 108, 255, 0.1);
|
||||
color: var(--a-mid);
|
||||
color: #b8adff;
|
||||
border: 1px solid rgba(124, 108, 255, 0.1);
|
||||
}
|
||||
|
||||
.role-tag.owner {
|
||||
background: linear-gradient(135deg, rgba(244, 63, 94, 0.15), rgba(251, 191, 36, 0.15));
|
||||
color: var(--st-queue);
|
||||
color: #fde68a;
|
||||
border-color: rgba(251, 191, 36, 0.2);
|
||||
}
|
||||
|
||||
.role-tag.admin {
|
||||
background: rgba(79, 124, 255, 0.12);
|
||||
color: var(--a-blue);
|
||||
color: #93c5fd;
|
||||
border-color: rgba(79, 124, 255, 0.2);
|
||||
}
|
||||
|
||||
.role-tag.viewer {
|
||||
background: rgba(107, 103, 150, 0.15);
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
border-color: rgba(107, 103, 150, 0.2);
|
||||
}
|
||||
|
||||
.user-date {
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
}
|
||||
|
||||
.loading-pulse {
|
||||
@@ -806,7 +806,7 @@ onMounted(() => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
@@ -849,7 +849,7 @@ onMounted(() => {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
}
|
||||
|
||||
@@ -861,7 +861,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
@@ -870,11 +870,11 @@ onMounted(() => {
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.req {
|
||||
color: var(--st-block);
|
||||
color: #fb7185;
|
||||
}
|
||||
|
||||
.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 {
|
||||
const lower = p.toLowerCase()
|
||||
if (lower === 'high') return 'var(--st-block)'
|
||||
if (lower === 'low') return 'var(--a-blue)'
|
||||
return 'var(--st-queue)'
|
||||
if (lower === 'high') return '#f87171'
|
||||
if (lower === 'low') return '#60a5fa'
|
||||
return '#facc15'
|
||||
}
|
||||
|
||||
function priorityLabel(p: string): string {
|
||||
@@ -638,7 +638,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
font-size: 12px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
cursor: pointer;
|
||||
@@ -648,7 +648,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
/* ── Loading / Error ────────────────────────── */
|
||||
@@ -659,14 +659,14 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
padding: 80px 20px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2.5px solid rgba(150, 140, 255, 0.15);
|
||||
border-top-color: var(--a-mid);
|
||||
border-top-color: #7c6cff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
@@ -675,7 +675,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(150, 140, 255, 0.15);
|
||||
border-top-color: var(--a-mid);
|
||||
border-top-color: #7c6cff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
flex-shrink: 0;
|
||||
@@ -686,7 +686,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
.error-state p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
color: var(--st-block);
|
||||
color: #fda4af;
|
||||
}
|
||||
|
||||
/* ── Detail Header ──────────────────────────── */
|
||||
@@ -711,15 +711,15 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
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-progress { color: var(--st-work); 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-blocked { color: var(--st-block); 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-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.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: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.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: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
|
||||
|
||||
.meta-chip {
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
background: rgba(10, 9, 24, 0.35);
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
@@ -735,7 +735,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border-radius: 10px;
|
||||
background: rgba(147,51,234,.08);
|
||||
border: 1px solid rgba(147,51,234,.18);
|
||||
color: var(--a-purple);
|
||||
color: #c084fc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -759,7 +759,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 4px 0;
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
font-family: 'Space Grotesk', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
@@ -777,7 +777,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
}
|
||||
|
||||
.meta-row span {
|
||||
@@ -791,7 +791,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border-radius: 12px;
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
border: 1px solid rgba(124, 108, 255, 0.14);
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -808,7 +808,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
@@ -826,7 +826,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: 1px solid rgba(150, 140, 255, 0.10);
|
||||
border-radius: 12px;
|
||||
background: rgba(10, 9, 24, 0.45);
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
@@ -897,13 +897,13 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
.subtask-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.subtask-detail {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11.5px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -919,7 +919,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
background: rgba(10, 9, 24, 0.5);
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
font-size: 10px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
cursor: pointer;
|
||||
@@ -927,8 +927,8 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
.state-select-mini option {
|
||||
background: var(--space-3);
|
||||
color: var(--tx);
|
||||
background: #141130;
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.state-select-mini:disabled {
|
||||
@@ -941,14 +941,14 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -996,7 +996,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(124, 108, 255, 0.08);
|
||||
color: var(--a-mid);
|
||||
color: #7c6cff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -1007,13 +1007,13 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
|
||||
.activity-msg {
|
||||
font-size: 12.5px;
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
@@ -1042,7 +1042,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
@@ -1060,7 +1060,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
.sidebar-field span {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
@@ -1079,13 +1079,13 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
.info-list dt {
|
||||
color: var(--tx-3);
|
||||
color: #6f6aa0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.info-list dd {
|
||||
margin: 0;
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
font-size: 11.5px;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -1104,7 +1104,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
background: rgba(10, 9, 24, 0.55);
|
||||
color: var(--tx);
|
||||
color: #ece9ff;
|
||||
font-size: 13.5px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
outline: none;
|
||||
@@ -1127,8 +1127,8 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
.galaxy-select option {
|
||||
background: var(--space-3);
|
||||
color: var(--tx);
|
||||
background: #141130;
|
||||
color: #ece9ff;
|
||||
}
|
||||
|
||||
.galaxy-select:disabled {
|
||||
@@ -1143,7 +1143,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
padding: 9px 16px;
|
||||
border: none;
|
||||
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;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
@@ -1169,7 +1169,7 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: 1px solid rgba(150, 140, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
font-size: 11px;
|
||||
font-family: 'Manrope', sans-serif;
|
||||
cursor: pointer;
|
||||
@@ -1186,13 +1186,13 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: rgba(124, 108, 255, 0.06);
|
||||
color: var(--tx-2);
|
||||
color: #a8a3d6;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon-sm:hover { background: rgba(124, 108, 255, 0.12); color: var(--tx); }
|
||||
.btn-icon-sm.danger:hover { background: rgba(244, 63, 94, 0.12); color: var(--st-block); }
|
||||
.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: #fda4af; }
|
||||
.btn-icon-sm:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.msg {
|
||||
@@ -1202,8 +1202,8 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
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.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.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: #86efac; display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* ── Responsive ──────────────────────────────── */
|
||||
@media (max-width: 860px) {
|
||||
|
||||
@@ -23,7 +23,7 @@ const agents: AgentCardData[] = [
|
||||
role: 'Chief of Staff',
|
||||
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
|
||||
tags: ['Orchestration', 'Delegation', 'Approval'],
|
||||
color: '#7c6cff',
|
||||
color: '#8b7cf6',
|
||||
icon: 'bot',
|
||||
hero: true,
|
||||
},
|
||||
@@ -33,7 +33,7 @@ const agents: AgentCardData[] = [
|
||||
role: 'Lead Developer',
|
||||
description: 'Implementiert Features, schreibt Code, führt Builds und Tests aus. Arbeitet autonom im Scope.',
|
||||
tags: ['coding', 'development', 'builds'],
|
||||
color: '#4f7cff',
|
||||
color: '#3b82f6',
|
||||
icon: 'code',
|
||||
},
|
||||
{
|
||||
@@ -42,7 +42,7 @@ const agents: AgentCardData[] = [
|
||||
role: 'Code QA',
|
||||
description: 'Prüft Code auf Bugs, Sicherheit und Wartbarkeit. Fixt Probleme eigenständig.',
|
||||
tags: ['Quality Assurance', 'Security', 'Code Review'],
|
||||
color: '#b557f6',
|
||||
color: '#a855f7',
|
||||
icon: 'shield',
|
||||
},
|
||||
{
|
||||
@@ -51,7 +51,7 @@ const agents: AgentCardData[] = [
|
||||
role: 'Research Analyst',
|
||||
description: 'Recherchiert, analysiert Quellen, prüft Fakten. Nur Lese-Rechte, keine Aktionen.',
|
||||
tags: ['Research', 'Analysis', 'Fact-Checking'],
|
||||
color: '#3ddc97',
|
||||
color: '#22c55e',
|
||||
icon: 'search',
|
||||
},
|
||||
{
|
||||
@@ -60,7 +60,7 @@ const agents: AgentCardData[] = [
|
||||
role: 'Host Executor',
|
||||
description: 'Führt Host-Kommandos auf dem VPS aus. Nur auf Iris-Befehl, niemals eigeninitiativ.',
|
||||
tags: ['Execution', 'Deployment', 'VPS'],
|
||||
color: '#fbbf24',
|
||||
color: '#eab308',
|
||||
icon: 'terminal',
|
||||
},
|
||||
]
|
||||
@@ -133,7 +133,7 @@ function goToAgent(id: string) {
|
||||
.quote-text {
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
color: var(--tx-2);
|
||||
color: #9ea5b3;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -144,17 +144,17 @@ function goToAgent(id: string) {
|
||||
.team-title {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
color: var(--tx);
|
||||
color: #e8eaf0;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.team-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.team-description {
|
||||
font-size: 10.5px;
|
||||
color: var(--tx-3);
|
||||
color: #6b7385;
|
||||
margin: 0;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
@@ -182,7 +182,7 @@ function goToAgent(id: string) {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--tx-3);
|
||||
color: #7e8799;
|
||||
}
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
@@ -191,11 +191,11 @@ function goToAgent(id: string) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.active-pulse {
|
||||
background: var(--st-work);
|
||||
background: #51d49a;
|
||||
box-shadow: 0 0 6px rgba(81, 212, 154, 0.6);
|
||||
}
|
||||
.idle-pulse {
|
||||
background: var(--line-2);
|
||||
background: #3a3f4b;
|
||||
}
|
||||
.pulse-dot {
|
||||
background: white;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Runtime und Routing
|
||||
|
||||
> Letzte Aktualisierung: 2026-07-09
|
||||
> Letzte Aktualisierung: 2026-06-16
|
||||
|
||||
## Aktive Modelle (7 von 8 konfiguriert)
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
|
||||
- Einzige aktive Integration: `OpenClawRuntime` über `IAgentRuntime`
|
||||
- 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.
|
||||
- Der frühere Pfad `host.docker.internal:18789` ist auf dem VPS nicht erreichbar und wurde entfernt.
|
||||
- API kommuniziert via `host.docker.internal:18789` (Gateway loopback — wird über `openclaw_default` Netzwerk gefixt)
|
||||
- **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)
|
||||
|
||||
Reference in New Issue
Block a user