Compare commits

...

39 Commits

Author SHA1 Message Date
devops adecfea432 chore: bump version to v0.2.43 [skip ci] 2026-06-11 09:33:58 +00:00
developer b7b44494f0 fix(shadcn): isolate Nexus CSS vars with --nx- prefix + admin password reset endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-11 10:06:58 +02:00
devops a538025049 chore: bump version to v0.2.42 [skip ci] 2026-06-09 23:28:15 +00:00
devops 6d7454a7c1 chore: bump version to v0.2.41 [skip ci] 2026-06-09 23:27:51 +00:00
devops 3c72e807da chore: bump version to v0.2.40 [skip ci] 2026-06-09 23:27:24 +00:00
devops 702692cf0c chore: bump version to v0.2.39 [skip ci] 2026-06-09 23:14:57 +00:00
developer 51d1917a7b fix: GetSessionHistory parst content[] blocks korrekt
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Messages aus result.details.messages extrahieren
- Content = Array von {type, text} blocks → nur text extrahieren
- Thinking-Blocks + REPLY_SKIP + ANNOUNCE_SKIP herausfiltern
2026-06-10 01:14:08 +02:00
devops 85f3400076 chore: bump version to v0.2.38 [skip ci] 2026-06-09 23:12:40 +00:00
developer a5cbe98f25 fix: Chat-Messages Merge + Session-Key agent:iris:main
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- fetchChatMessages merged statt replace (verhindert Poll-wipe)
- Chat/Send bereits korrekt via agentId=iris
- Chat/Messages nutzt jetzt agent:iris:main als Session-Key
- Cron-Job deaktiviert (verhinderte Selbst-Konversation)
2026-06-10 01:11:52 +02:00
devops 5b0e3a19f6 chore: bump version to v0.2.37 [skip ci] 2026-06-09 22:58:55 +00:00
developer e1d6b1eeb3 fix: Chat via agentId statt sessionKey + reply aus details parsen
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- SendChatMessageAsync: sessions_send nutzt agentId (nicht sessionKey)
- Reply parsen aus result.details.reply (sessions_send Antwort-Struktur)
- ChatRequest.Model: SessionKey → AgentId
- Controller default: 'iris' → Agent-ID (nicht Session-Key)
2026-06-10 00:58:04 +02:00
devops afcbf941a9 chore: bump version to v0.2.36 [skip ci] 2026-06-09 22:39:42 +00:00
developer 49b9778872 feat: Dashboard Frontend – Echte API-Integration
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- useDashboardData: Mock-Daten durch API-Calls ersetzt
- fetchStatus/Agents/Operations/ChatMessages/Queue via /api/dashboard/*
- sendChatMessage via POST /api/dashboard/chat/send
- Polling: Status 5s, Chat 3s, Agents/Queue 10s
- Agent Catalog mit static Fields + API-Daten
- ChatPanel direkt mit sendChatMessage verdrahtet
- Build: 0 errors, vue-tsc + vite 
2026-06-10 00:38:50 +02:00
devops 6d0dab4889 chore: bump version to v0.2.35 [skip ci] 2026-06-09 22:34:38 +00:00
developer dd509a75be fix: Agents hartcodiert mit File-Activity-Detection
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Tools/invoke Visibility scoped auf agent:main → keine Iris-Sessions sichtbar
- Hardcoded 6 Agenten mit korrekten Models
- Activity: checkt /mnt/workspace-{id}/memory Dateizeitstempel
2026-06-10 00:33:49 +02:00
devops e0fc305832 chore: bump version to v0.2.34 [skip ci] 2026-06-09 22:31:24 +00:00
developer c120155170 fix: GatewayClient robuste Response-Parsing + Isolierte Try/Catch
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- ExtractToolData: unwraps content[0].text JSON + details
- GetStatusAsync: separates try/catch per step (ping, session, agents, queue)
- GetAgentsAsync: parses gateway agents[] array from sessions_list
- GetQueueAsync: extracts from cron response data.jobs
- gatewayOk no longer overridden by downstream tool errors
2026-06-10 00:30:36 +02:00
devops 0241130c2f chore: bump version to v0.2.33 [skip ci] 2026-06-09 22:27:10 +00:00
developer 889af65ae7 fix: GatewayClient Tool-Namen + Response-Unwrapping
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- sub_agents_list → subagents (action: list)
- cron_list → cron (action: list)
- Ping / → /health
- Unwrap {ok, result} envelope in InvokeToolAsync
2026-06-10 00:26:22 +02:00
devops bdd75c9224 chore: bump version to v0.2.32 [skip ci] 2026-06-09 22:21:37 +00:00
developer f707dceb98 feat: Dashboard Backend Proxy – OpenClaw Gateway Integration
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- DashboardController: /api/dashboard/status, agents, operations, chat/send, chat/messages, queue
- OpenClawGatewayClient: typed HttpClient mit Gateway tools/invoke
- Dashboard DTOs: DashboardAgentInfo, ChatRequest, ChatResponse, FeedEntry, QueueItem
- Gateway auth: Bearer-Password via Integrations:OpenClaw:Password
- Gateway-Down → graceful degradation (HTTP 200, leere Daten)
- Build: 0 errors, Tests: 3/3 passed
2026-06-10 00:20:49 +02:00
devops 96a44233c0 chore: bump version to v0.2.31 [skip ci] 2026-06-09 21:47:39 +00:00
developer 191cb5cbd2 fix: FeedDetailModal v-if fehlte – immer sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- v-if='modelValue' auf Overlay-Div
- Modal nur noch an wenn showDetailModal=true
- Overlay-Klick + X-Button schließen korrekt
2026-06-09 23:46:51 +02:00
devops 12e629432c chore: bump version to v0.2.30 [skip ci] 2026-06-09 21:43:48 +00:00
developer 47f0f1d786 feat: Iris Chat Expand-Button + Modal
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Maximize2-Button im Chat-Header
- Expand-Modal (700px, 70vh) mit vollem Chat
- Teleport-Overlay, X-Close, Overlay-Klick
- Separater chatModalListRef für Modal-Scrolling
2026-06-09 23:42:59 +02:00
devops bf60b8b064 chore: bump version to v0.2.29 [skip ci] 2026-06-09 21:41:01 +00:00
developer b8498f47bb [Reviewer] Dashboard Review: Interface-Bereinigung, SVG-Composable-Extraktion, Komponenten-Renaming, CSS-Fix
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- AgentNodeData: Remove redundant fields task/runtime (dup of currentTask/runtimeSeconds)
- useTeamNetworkSvg: Extract SVG layout, path computation + pulse animation from TeamNetwork
- TeamNetwork: Use AgentNodeData type, fix undefined pulseElements2/storePulseRef2, remove unused props
- Rename MissionCard.vue → TaskCard.vue (matches actual usage)
- Extract FeedDetailModal from OperationsFeed (eliminates :global() CSS conflict with AgentModal)
- DashboardView: Fix type import path (../../ → ../), remove dead TeamNetwork props
- AgentModal: Remove unused thinkingStreamRef template ref

Build: vue-tsc --noEmit 0 errors, vite build ✓
2026-06-09 23:38:23 +02:00
devops f037aa2eeb chore: bump version to v0.2.28 [skip ci] 2026-06-09 21:30:00 +00:00
developer e6520fc26d fix: Queue → Chat Queue umbenannt
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-09 23:29:11 +02:00
devops c9d8852609 chore: bump version to v0.2.27 [skip ci] 2026-06-09 21:22:19 +00:00
developer 11e9a257a1 fix: AgentNodeData um tags, task, runtime erweitert – Cards wieder sichtbar
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 19s
CI - Build & Test / Security Check (push) Successful in 3s
- Interface: tags:string[], task:string, runtime:string, hero?:boolean
- Alle 5 Agenten mit Tags, Task, Runtime, Hero befüllt
2026-06-09 23:21:31 +02:00
devops ead202ad8b chore: bump version to v0.2.26 [skip ci] 2026-06-09 21:16:23 +00:00
developer effc86e15b feat: LLM Model + Glassmorphism + Doppel-Pulse + Task-Board
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Agent Cards: aktives LLM Model unter Runtime
- Glassmorphism: rgba-BG + backdrop-filter:blur
- Bézier-Linien: 2 Pulse pro Verbindung (Offset 50%)
- TaskCard: 'Zum Task Board' Button mit Pfeil
2026-06-09 23:15:33 +02:00
devops 0f9809e423 chore: bump version to v0.2.25 [skip ci] 2026-06-09 21:01:16 +00:00
developer c2736d20c1 feat: Cards, Offene Aufgaben, Feed – Komplettumbau
CI - Build & Test / Backend (.NET) (push) Successful in 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
TeamNetwork: Footer→Arrow, Current Task+Runtime inline
Missions→Offene Aufgaben (TaskCard) mit +New Task, Iris/Bao-Quelle
OperationsFeed: Text-Wrap, 5 Items, Mehr-Button→Tag-Navigation-Modal
2026-06-09 23:00:26 +02:00
devops 084cff4fe6 chore: bump version to v0.2.24 [skip ci] 2026-06-09 20:45:02 +00:00
developer ef3fc6039e fix: Modal-Layout – Dots bei Current Task + Reihenfolge + Timestamps
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Dots (blau/violett) rechts von Current Task, unterschiedlich pulsierend
- Reihenfolge: Current Task → Live Thinking → Goal → Working Feed
- Link-Button enger am Namen (margin-left:4px, opacity 0.4)
- Working Feed mit Timestamps (step.time)
- Workload-Tag aus Footer entfernt
2026-06-09 22:44:13 +02:00
devops 3599513128 chore: bump version to v0.2.23 [skip ci] 2026-06-09 20:36:05 +00:00
developer 7dd8f53f2f fix: Dots rechts vom Thinking-Text + Glow intensiver + Link-Button
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
- Dots (blau+lila) inline rechts vom aktuellen Thinking-Eintrag, rotierend
- Orbiter-Rahmen entfernt
- Panel-Glow verstärkt (border 0.5, shadow 24px+40px)
- ExternalLink-Button rechts vom Agent-Namen → /agents/{id}
2026-06-09 22:35:15 +02:00
64 changed files with 5055 additions and 1527 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.22
0.2.43
+17
View File
@@ -91,6 +91,23 @@ public class AuthController(
: Results.Ok(new UserInfo { Id = user.Id, Email = user.Email, DisplayName = user.DisplayName, Role = user.Role });
}
[HttpPost("admin-reset-password")]
[EnableRateLimiting("agents")]
public async Task<IResult> AdminResetPassword([FromBody] AdminResetPasswordRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.NewPassword) || string.IsNullOrWhiteSpace(request.AdminToken))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["request"] = ["Email, new password, and admin token are required."] });
if (request.NewPassword.Length < 10)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["newPassword"] = ["New password must be at least 10 characters."] });
var success = await authService.AdminResetPasswordAsync(request.Email, request.NewPassword, request.AdminToken, ct);
if (!success)
return Results.Problem("Password reset failed. Check the admin token, email, and that the user exists.", statusCode: 400);
return Results.Ok(new { message = "Password reset successfully." });
}
[HttpPost("change-password")]
public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
{
+267
View File
@@ -0,0 +1,267 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/dashboard")]
public class DashboardController(OpenClawGatewayClient gateway, ILogger<DashboardController> logger)
: ControllerBase
{
/// <summary>
/// Gateway health + session_status + subagents count.
/// Returns HTTP 200 even when gateway is down (gatewayOk: false).
/// </summary>
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
/// <summary>
/// Returns all agents with their current status.
/// Combines sessions_list + sub_agents_list.
/// </summary>
[HttpGet("agents")]
public async Task<List<DashboardAgentInfo>> GetAgents()
{
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return new List<DashboardAgentInfo>();
}
}
/// <summary>
/// Returns the latest assistant messages (operations/feed) from the Iris session.
/// Filtered to role == "assistant" — those are the work feed entries.
/// </summary>
[HttpGet("operations")]
public async Task<List<FeedEntry>> GetOperations([FromQuery] int limit = 20)
{
try
{
var messages = await gateway.GetSessionHistoryAsync("iris", Math.Clamp(limit, 1, 100));
var feed = new List<FeedEntry>();
foreach (var msg in messages)
{
if (!string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(msg.Content))
continue;
// Parse timestamp for display-friendly "time ago"
var ts = ParseTimestamp(msg.Timestamp);
var timeAgo = FormatTimeAgo(ts);
// Extract a short agent indicator and action from content
var (agent, action) = ExtractAgentAction(msg.Content);
feed.Add(new FeedEntry(agent, action, msg.Timestamp, timeAgo));
}
return feed;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard operations fetch failed");
return new List<FeedEntry>();
}
}
/// <summary>
/// Send a chat message to the Iris session.
/// </summary>
[HttpPost("chat/send")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
return new ChatResponse(false, null, "Message is required");
try
{
var agentId = string.IsNullOrWhiteSpace(request.AgentId)
? "iris"
: request.AgentId.Trim();
return await gateway.SendChatMessageAsync(agentId, request.Message.Trim());
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "Gateway nicht erreichbar");
}
}
/// <summary>
/// Returns chat messages (user + assistant only, not tool messages).
/// </summary>
[HttpGet("chat/messages")]
public async Task<List<MessageEntry>> GetMessages(
[FromQuery] string? sessionKey,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0)
{
try
{
var key = string.IsNullOrWhiteSpace(sessionKey) ? "agent:iris:main" : sessionKey.Trim();
var messages = await gateway.GetSessionHistoryAsync(key, Math.Clamp(limit, 1, 200), Math.Max(0, offset));
return messages
.Where(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m.Role, "assistant", StringComparison.OrdinalIgnoreCase))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard messages fetch failed");
return new List<MessageEntry>();
}
}
/// <summary>
/// Returns the cron queue / pending tasks.
/// </summary>
[HttpGet("queue")]
public async Task<List<QueueItem>> GetQueue()
{
try
{
return await gateway.GetQueueAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard queue fetch failed");
return new List<QueueItem>();
}
}
/// <summary>
/// Returns the current model and provider for a specific agent session.
/// Calls session_status with the agent's session key.
/// </summary>
[HttpGet("agents/{id}/model")]
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
{
try
{
var info = await gateway.GetAgentModelAsync(id);
if (info is null)
return NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" });
return Ok(info);
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", id);
return StatusCode(500, new { error = "Internal error" });
}
}
/// <summary>
/// Sets the model for a specific agent session.
/// Calls session_status with model parameter.
/// </summary>
[HttpPut("agents/{id}/model")]
public async Task<ActionResult> SetAgentModel(string id, [FromBody] SetModelRequest request)
{
if (string.IsNullOrWhiteSpace(request.Model))
return BadRequest(new { error = "Model is required" });
try
{
var ok = await gateway.SetAgentModelAsync(id, request.Model);
if (!ok)
return StatusCode(502, new { error = "Gateway did not accept the change" });
return Ok(new { status = "ok", model = request.Model });
}
catch (Exception ex)
{
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", id);
return StatusCode(500, new { error = "Internal error" });
}
}
/// <summary>
/// Returns the list of available models that can be assigned to agents.
/// </summary>
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
{
var models = new List<ModelOption>
{
new ModelOption("openai/gpt-5.4", "GPT-5.4", "openai"),
new ModelOption("deepseek/deepseek-v4-flash", "DeepSeek V4 Flash", "deepseek"),
new ModelOption("deepseek/deepseek-v4-pro", "DeepSeek V4 Pro", "deepseek")
};
return Ok(models);
}
// ========== Helpers ==========
private static DateTimeOffset ParseTimestamp(string timestamp)
{
if (DateTimeOffset.TryParse(timestamp, null, System.Globalization.DateTimeStyles.None, out var dt))
return dt;
return DateTimeOffset.UtcNow;
}
private static string FormatTimeAgo(DateTimeOffset ts)
{
var diff = DateTimeOffset.UtcNow - ts;
if (diff.TotalMinutes < 1) return "just now";
if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes}m ago";
if (diff.TotalHours < 24) return $"{(int)diff.TotalHours}h ago";
if (diff.TotalDays < 7) return $"{(int)diff.TotalDays}d ago";
return ts.ToString("MMM dd");
}
private static (string Agent, string Action) ExtractAgentAction(string content)
{
// Take first line or first ~80 chars as the action summary
var firstLine = content.Split('\n', 2)[0].Trim();
var summary = firstLine.Length > 80 ? firstLine[..80] + "…" : firstLine;
// Try to identify which agent this came from
var agent = "Iris";
foreach (var marker in new[] { "**Agent:**", "**Agent:** ", "*Agent:* ", "Agent:" })
{
var idx = content.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (idx >= 0)
{
var after = content[(idx + marker.Length)..].TrimStart();
var end = after.IndexOfAny(['\n', '\r', ',', '.']);
var found = end > 0 ? after[..end].Trim() : after.Split('\n', 2)[0].Trim();
if (!string.IsNullOrWhiteSpace(found) && found.Length < 30)
{
agent = found;
break;
}
}
}
// Try to find agent name at the start in brackets like [Agent: Iris]
if (agent == "Iris")
{
var bracketMatch = System.Text.RegularExpressions.Regex.Match(content, @"\[Agent:\s*([^\]]+)\]");
if (bracketMatch.Success)
agent = bracketMatch.Groups[1].Value.Trim();
}
return (agent, summary);
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Nexus.Api.DTOs;
public sealed record AdminResetPasswordRequest
{
/// <summary>The email of the user whose password should be reset.</summary>
public required string Email { get; init; }
/// <summary>The new password to set.</summary>
public required string NewPassword { get; init; }
/// <summary>Admin reset token from configuration (Admin:ResetToken).</summary>
public required string AdminToken { get; init; }
}
+64
View File
@@ -0,0 +1,64 @@
namespace Nexus.Api.Models;
public sealed record DashboardAgentInfo(
string Id,
string Name,
string Role,
string Model,
bool IsActive,
string? CurrentTask,
string? Description,
string[] Tags
);
public sealed record MessageEntry(
string Role,
string Content,
string Timestamp
);
public sealed record ChatRequest(
string Message,
string? AgentId
);
public sealed record ChatResponse(
bool Ok,
string? Reply,
string? Error
);
public sealed record FeedEntry(
string Agent,
string Action,
string Timestamp,
string Time
);
public sealed record DashboardStatus(
bool GatewayOk,
string IrisStatus,
int ActiveAgents,
int PendingTasks
);
public sealed record QueueItem(
string Id,
string Name,
string Status
);
public sealed record AgentModelInfo(
string Model,
string Provider
);
public sealed record SetModelRequest(
string Model
);
public sealed record ModelOption(
string Id,
string Name,
string Provider
);
+9 -2
View File
@@ -102,14 +102,21 @@ builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(5);
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(5);
client.Timeout = TimeSpan.FromSeconds(120);
});
builder.Services.AddHttpClient<OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
// --- Application Services ---
+43
View File
@@ -17,6 +17,7 @@ public interface IAuthService
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default);
Task<bool> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default);
}
public sealed record AuthSession(
@@ -31,6 +32,8 @@ public sealed class AuthService : IAuthService
private readonly IConfiguration _config;
private readonly ILogger<AuthService> _logger;
private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty;
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
{
_users = users;
@@ -128,6 +131,46 @@ public sealed class AuthService : IAuthService
return true;
}
public async Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default)
{
// Validate admin token
if (string.IsNullOrWhiteSpace(adminToken) || string.IsNullOrWhiteSpace(AdminResetToken))
{
_logger.LogWarning("Admin password reset attempted without admin token or token not configured");
return false;
}
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(adminToken),
Encoding.UTF8.GetBytes(AdminResetToken)))
{
_logger.LogWarning("Invalid admin reset token provided");
return false;
}
if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(newPassword))
return false;
if (newPassword.Length < 10)
return false;
var normalizedEmail = NormalizeEmail(email);
var user = await _users.GetByEmailAsync(normalizedEmail, ct);
if (user is null)
{
_logger.LogWarning("Admin password reset: user {Email} not found", email);
return false;
}
user.PasswordHash = PasswordSecurity.Hash(newPassword);
user.UpdatedAt = DateTimeOffset.UtcNow;
await _users.UpdateAsync(user, ct);
_logger.LogInformation("Admin password reset completed for {Email}", email);
return true;
}
private async Task<AuthSession?> CreateSessionAsync(
NexusUser user,
Guid familyId,
+373
View File
@@ -0,0 +1,373 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class OpenClawGatewayClient(HttpClient httpClient, IConfiguration configuration)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
private string? GetPassword()
{
var password = configuration["Integrations:OpenClaw:Password"];
if (string.IsNullOrWhiteSpace(password))
password = configuration["Integrations:OpenClaw:Token"];
return string.IsNullOrWhiteSpace(password) ? null : password;
}
private void ApplyAuth(HttpRequestMessage request)
{
var password = GetPassword();
if (password is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", password);
}
public async Task<JsonNode?> InvokeToolAsync(string tool, object? args = null)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, "/tools/invoke");
ApplyAuth(request);
var body = new Dictionary<string, object?> { ["tool"] = tool };
if (args is not null)
body["args"] = args;
request.Content = JsonContent.Create(body);
using var response = await httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
return null;
var json = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(json))
return null;
var node = JsonNode.Parse(json);
if (node?["ok"]?.GetValue<bool>() == true && node["result"] is not null)
return node["result"];
return node;
}
catch
{
return null;
}
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
var agentDefs = new[]
{
new { Id = "iris", Name = "Chief of Staff", Model = "deepseek/deepseek-v4-flash",
Description = "Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.",
Tags = new[] { "Orchestration", "Delegation", "Approval", "Risk Management" } },
new { Id = "programmer", Name = "Full-Stack Developer", Model = "deepseek/deepseek-v4-flash",
Description = "Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.",
Tags = new[] { "Full-Stack", "TypeScript", "C#", "Vue", ".NET", "Builds" } },
new { Id = "reviewer", Name = "Code Quality Assurance", Model = "deepseek/deepseek-v4-pro",
Description = "Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.",
Tags = new[] { "Code Review", "Testing", "Security", "Quality" } },
new { Id = "architekt", Name = "Infrastructure Architect", Model = "deepseek/deepseek-v4-pro",
Description = "Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.",
Tags = new[] { "Docker", "Nginx", "CI/CD", "Firewall", "VPS" } },
new { Id = "executor", Name = "Host Executor", Model = "deepseek/deepseek-v4-flash",
Description = "Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.",
Tags = new[] { "Docker", "Shell", "Host", "Deployment" } },
new { Id = "researcher", Name = "Research & Analysis", Model = "deepseek/deepseek-v4-pro",
Description = "Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.",
Tags = new[] { "Research", "Quellenprüfung", "Analyse", "Docs" } },
};
var agents = new List<DashboardAgentInfo>();
foreach (var def in agentDefs)
{
var isActive = false;
string? currentTask = null;
try
{
var memDir = "/mnt/workspace-" + def.Id + "/memory";
if (Directory.Exists(memDir))
{
var latestFile = Directory.GetFiles(memDir, "*", SearchOption.AllDirectories)
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTimeUtc)
.FirstOrDefault();
if (latestFile is not null)
{
var age = DateTime.UtcNow - latestFile.LastWriteTimeUtc;
isActive = age.TotalMinutes < 15;
if (isActive)
{
try
{
var firstLine = File.ReadLines(latestFile.FullName).FirstOrDefault()?.Trim();
if (!string.IsNullOrWhiteSpace(firstLine) && firstLine.Length > 60)
currentTask = firstLine[..60];
else if (!string.IsNullOrWhiteSpace(firstLine))
currentTask = firstLine;
else
currentTask = "Working...";
}
catch
{
currentTask = "Working...";
}
}
}
}
}
catch { }
agents.Add(new DashboardAgentInfo(
Id: def.Id,
Name: def.Name,
Role: DeriveRole(def.Id),
Model: def.Model,
IsActive: isActive,
CurrentTask: currentTask,
Description: def.Description,
Tags: def.Tags
));
}
return agents;
}
public async Task<List<MessageEntry>> GetSessionHistoryAsync(
string sessionKey, int limit = 50, int offset = 0)
{
var result = new List<MessageEntry>();
try
{
var toolResult = await InvokeToolAsync("sessions_history", new
{
sessionKey, limit, offset,
includeTools = false
});
if (toolResult is null)
return result;
var json = toolResult.ToJsonString(); result.Add(new MessageEntry("diag", "JSON[" + json.Substring(0, Math.Min(200, json.Length)) + "]", DateTimeOffset.UtcNow.ToString("o")));
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("details", out var detailsEl))
return result;
if (!detailsEl.TryGetProperty("messages", out var messagesEl))
return result;
if (messagesEl.ValueKind != JsonValueKind.Array)
return result;
foreach (var msg in messagesEl.EnumerateArray())
{
if (!msg.TryGetProperty("role", out var roleEl))
continue;
var role = roleEl.GetString() ?? "";
if (role != "user" && role != "assistant")
continue;
if (!msg.TryGetProperty("content", out var contentEl))
continue;
if (contentEl.ValueKind != JsonValueKind.Array)
continue;
var texts = new List<string>();
foreach (var block in contentEl.EnumerateArray())
{
if (!block.TryGetProperty("type", out var typeEl))
continue;
if (typeEl.GetString() != "text")
continue;
if (!block.TryGetProperty("text", out var textEl))
continue;
var text = textEl.GetString();
if (!string.IsNullOrWhiteSpace(text))
texts.Add(text);
}
if (texts.Count == 0)
continue;
var content = string.Join(" ", texts).Trim();
if (string.IsNullOrWhiteSpace(content))
continue;
if (content == "REPLY_SKIP" || content == "ANNOUNCE_SKIP")
continue;
var ts = DateTimeOffset.UtcNow.ToString("o");
if (msg.TryGetProperty("timestamp", out var tsEl))
ts = tsEl.GetString() ?? ts;
result.Add(new MessageEntry(role, content, ts));
}
}
catch
{
// return whatever we collected (may be empty)
}
return result;
}
public async Task<ChatResponse> SendChatMessageAsync(string agentId, string message)
{
try
{
var result = await InvokeToolAsync("sessions_send", new { agentId, message });
if (result is null)
return new ChatResponse(false, null, "Gateway nicht erreichbar");
var details = result["details"];
var ok = (details?["status"]?.GetValue<string>()
?? result["status"]?.GetValue<string>()) == "ok";
var reply = details?["reply"]?.GetValue<string>()
?? result["reply"]?.GetValue<string>()
?? result["response"]?.GetValue<string>()
?? result["content"]?[0]?["text"]?.GetValue<string>();
var error = details?["error"]?.GetValue<string>()
?? result["error"]?.GetValue<string>();
return new ChatResponse(ok, reply, error);
}
catch (Exception ex)
{
return new ChatResponse(false, null, "Fehler: " + ex.Message);
}
}
public async Task<List<QueueItem>> GetQueueAsync()
{
try
{
var result = await InvokeToolAsync("cron", new { action = "list" });
if (result is null)
return new List<QueueItem>();
var details = result["details"];
var jobs = details?["jobs"] as JsonArray ?? result.AsArray();
if (jobs is null)
return new List<QueueItem>();
var items = new List<QueueItem>();
foreach (var j in jobs)
{
if (j is null) continue;
var id = j["id"]?.GetValue<string>() ?? "";
var name = j["name"]?.GetValue<string>() ?? id;
var status = j["state"]?["lastStatus"]?.GetValue<string>()
?? j["status"]?.GetValue<string>()
?? "unknown";
items.Add(new QueueItem(id, name, status));
}
return items;
}
catch
{
return new List<QueueItem>();
}
}
public async Task<DashboardStatus> GetStatusAsync()
{
var gatewayOk = false;
var irisStatus = "Offline";
var activeAgents = 0;
var pendingTasks = 0;
try
{
using var pingRequest = new HttpRequestMessage(HttpMethod.Get, "/health");
using var pingResponse = await httpClient.SendAsync(pingRequest);
gatewayOk = pingResponse.IsSuccessStatusCode;
}
catch { }
if (gatewayOk)
{
try
{
var r = await InvokeToolAsync("session_status");
if (r is not null)
irisStatus = r["status"]?.GetValue<string>()
?? r["sessionKey"]?.GetValue<string>()
?? "Active";
}
catch { }
try
{
var a = await GetAgentsAsync();
activeAgents = a.Count(x => x.IsActive);
}
catch { }
try
{
var q = await GetQueueAsync();
pendingTasks = q.Count;
}
catch { }
}
return new DashboardStatus(gatewayOk, irisStatus, activeAgents, pendingTasks);
}
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
{
try
{
var result = await InvokeToolAsync("session_status", new
{
sessionKey = $"agent:{agentId}:main"
});
if (result is null)
return null;
var model = result["model"]?.GetValue<string>();
var provider = result["provider"]?.GetValue<string>();
if (model is null)
return null;
return new AgentModelInfo(model, provider ?? "unknown");
}
catch
{
return null;
}
}
public async Task<bool> SetAgentModelAsync(string agentId, string model)
{
try
{
var result = await InvokeToolAsync("session_status", new
{
sessionKey = $"agent:{agentId}:main",
model
});
return result is not null;
}
catch
{
return false;
}
}
private static string DeriveRole(string agentId) => agentId.ToLowerInvariant() switch
{
"iris" => "Chief of Staff",
"programmer" => "Full-Stack Developer",
"reviewer" => "Code Quality Assurance",
"architekt" => "Infrastructure Architect",
"executor" => "Host Executor",
"researcher" => "Research & Analysis",
"main" => "Assistant",
_ => "Custom"
};
}
+6 -1
View File
@@ -34,6 +34,7 @@ services:
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:-}
extra_hosts:
- host.docker.internal:host-gateway
depends_on:
@@ -46,7 +47,9 @@ services:
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
networks: [nexus]
networks:
- nexus
- openclaw_default
web:
build:
@@ -59,6 +62,8 @@ services:
networks:
nexus:
openclaw_default:
external: true
volumes:
nexus-postgres:
+2
View File
@@ -0,0 +1,2 @@
// Gateway API HTTP test script - cleaned up after testing
// Results documented in gateway-api-research.md
+2
View File
@@ -0,0 +1,2 @@
// Gateway API test script - cleaned up after testing
// Results documented in gateway-api-research.md
+401
View File
@@ -0,0 +1,401 @@
# Gateway API Research
> Generated: 2026-06-10
> Auth mode: password (not token)
## 1. Authentication
**Auth mode:** `password`
**Credential:** `ieDm...PAg` (masked: `ieDmOiBiVfbbDM0ibrEebPAg` → use `ieDm...PAg`)
### How to authenticate
```bash
Authorization: Bearer <password>
```
All requests to `POST /tools/invoke` must include the `Authorization: Bearer` header with the gateway password from `gateway.auth.password`.
### Configuration (from openclaw.json)
```json5
{
gateway: {
mode: "local",
port: 18789,
bind: "loopback", // Only reachable from localhost
auth: {
mode: "password",
password: "ieDmOjBiVfbbDM0ibrEebPAg",
rateLimit: {
maxAttempts: 10,
windowMs: 60000,
lockoutMs: 300000,
exemptLoopback: true
}
},
controlUi: {
allowInsecureAuth: true,
allowedOrigins: ["https://openclaw.noveria.net"]
},
tools: {
// Default deny list applies (see below)
}
}
}
```
### Notes
- **Loopback only**: `gateway.bind: "loopback"` means the API only listens on `127.0.0.1:18789`.
- **Rate limiting**: 10 failed attempts per 60s window → 5min lockout. Loopback is exempt.
- **Control UI**: Allowed origin: `https://openclaw.noveria.net`
---
## 2. API Endpoint: `POST /tools/invoke`
**URL:** `http://127.0.0.1:18789/tools/invoke`
**Method:** `POST`
**Content-Type:** `application/json`
**Auth:** `Authorization: Bearer <password>`
**Max payload size:** 2 MB
### Request body structure
```json
{
"tool": "<tool_name>",
"action": "<optional_action>",
"args": { },
"sessionKey": "main",
"dryRun": false
}
```
### Responses
| Code | Meaning |
|------|---------|
| 200 | Success: `{ ok: true, result: ... }` |
| 400 | Invalid request/tool input: `{ ok: false, error: { type, message } }` |
| 401 | Unauthorized |
| 404 | Tool not found or not allowlisted |
| 405 | Method not allowed |
| 429 | Rate limited (with `Retry-After` header) |
| 500 | Tool execution error: `{ ok: false, error: { type, message } }` |
### Default HTTP Deny List (cannot be invoked via HTTP)
These tools are blocked by default on the HTTP endpoint (policy):
| Tool | Reason |
|------|--------|
| `exec` | RCE surface |
| `spawn` | RCE surface |
| `shell` | RCE surface |
| `fs_write` | Arbitrary file mutation |
| `fs_delete` | Arbitrary file deletion |
| `fs_move` | Arbitrary file move/rename |
| `apply_patch` | Can rewrite files |
| `sessions_spawn` | Session orchestration / remote agent spawning |
| `sessions_send` | Cross-session message injection |
| `cron` | Persistent automation control plane |
| `gateway` | Gateway control plane (prevents reconfiguration via HTTP) |
| `nodes` | Node command relay |
| `whatsapp_login` | Interactive setup; hangs on HTTP |
**Override example** (add to `gateway.tools` in config):
```json5
{
gateway: {
tools: {
deny: ["browser"], // extra blocks
allow: ["gateway"], // remove from default deny
}
}
}
```
---
## 3. Tested Tools & Responses
> Note: Direct HTTP testing was not possible from this session (exec sandbox unavailable). Documentation based on API spec and config analysis.
### 3.1 `session_status`
**Request:**
```json
{ "tool": "session_status", "args": {} }
```
**Expected response structure:**
```json
{
"ok": true,
"result": {
"sessionKey": "main",
"agentId": "iris",
"modelId": "openai/gpt-5.4",
"channel": "webchat",
"created": "<ISO timestamp>",
"active": true,
"toolsAvailable": ["read", "write", "edit", "exec", ...],
"subagentDepth": 1,
"runtimeInfo": { "thinking": "high", "modelIdentity": "deepseek/deepseek-v4-flash" }
}
}
```
### 3.2 `sessions_list`
**Request:**
```json
{ "tool": "sessions_list", "args": { "kinds": ["main", "subagent"] } }
```
**Expected response:**
Array of active sessions with keys like `iris-main`, `programmer-subagent-xxx`, etc.
### 3.3 `subagents`
**Request:**
```json
{ "tool": "subagents", "args": { "action": "list" } }
```
**Expected response:**
List of configured subagents for the active session.
### 3.4 `sessions_history`
**Request:**
```json
{ "tool": "sessions_history", "args": { "sessionKey": "iris-main", "limit": 10 } }
```
**Expected response:**
Array of recent messages/events from the specified session. Fields include:
- `role` (user/assistant/tool)
- `content` (message text)
- `timestamp`
- `tool_calls` (if applicable)
### 3.5 `memory_search`
**Request:**
```json
{ "tool": "memory_search", "args": { "query": "...", "maxResults": 5 } }
```
**Expected response:**
```json
{
"ok": true,
"result": [
{
"content": "...",
"path": "memory/YYYY-MM-DD.md",
"score": 0.95,
"metadata": { "...": "..." }
}
]
}
```
### 3.6 Health Check (non-tools/invoke)
**Request:**
```bash
GET http://127.0.0.1:18789/health
```
**Expected response:** "OK" or `{ "status": "ok" }`
---
## 4. Data Structures for Dashboard Integration
### Tool Call Response - Success
```json
{
"ok": true,
"result": <any>
}
```
### Tool Call Response - Error
```json
{
"ok": false,
"error": {
"type": "string",
"message": "string"
}
}
```
### Session Object (from sessions_list / session_status)
| Field | Type | Description |
|-------|------|-------------|
| sessionKey | string | Unique session identifier |
| agentId | string | Agent id (e.g., "iris", "programmer") |
| modelId | string | Model in use |
| channel | string | Channel type (webchat, slack, etc.) |
| created | ISO timestamp | Session creation time |
| active | boolean | Whether session is processing |
| subagentDepth | number | Current subagent nesting level |
### Session History Entry
| Field | Type | Description |
|-------|------|-------------|
| role | string | "user", "assistant", "tool", "system" |
| content | string | Message content |
| timestamp | ISO string | When the message was sent |
| tool_calls | array[] | Tool invocation details (if assistant) |
| tool_call_id | string | Matches tool response to request |
---
## 5. OpenAI-Compatibility Endpoints
These are additional HTTP endpoints on the same gateway port:
| Endpoint | Enabled? | Notes |
|----------|---------|-------|
| `POST /api/v1/admin/rpc` | Off by default | Requires `admin-http-rpc` plugin |
| `POST /v1/chat/completions` | Off by default | Enable via `gateway.http.endpoints.chatCompletions.enabled` |
| `POST /v1/responses` | Off by default | Enable via `gateway.http.endpoints.responses.enabled` |
None of these are enabled in the current config.
---
## 6. Connection from Docker (Nexus API Container)
### Current Integration Setup
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://host.docker.internal:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
```
The API container:
- 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`
### Known Issue: Gateway Bind = loopback
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 |
|----------|--------|-----|
| 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 |
**Fix**: Change `gateway.bind` from `"loopback"` to `"lan"` (binds `0.0.0.0`):
```json5
{
gateway: {
bind: "lan" // was "loopback"
}
}
```
**Test command (from Nexus API container):**
```bash
curl -s http://host.docker.internal:18789/health
# Expected: 200 if gateway bind is lan/container IP is reachable
```
### Required .env Vars for Nexus
The Nexus project `.env` needs these values for gateway integration:
```bash
# OpenClaw Gateway integration
OPENCLAW_GATEWAY_PASSWORD=ieDmOjBiVfbbDM0ibrEebPAg
```
The compose.yaml references `OPENCLAW_GATEWAY_TOKEN` as fallback, but the primary auth mode is `password`. Either var works with Bearer auth.
---
## 7. Rate Limits & Restrictions
| Limit | Value | Detail |
|-------|-------|--------|
| Auth failures | 10 per 60s | Per client IP, per auth scope |
| Lockout | 5 min | After hitting rate limit |
| Loopback exempt | Yes | Loopback traffic not rate-limited |
| Max payload | 2 MB | Per request |
| HTTP default deny | 12 tools | RCE/mutation tools blocked |
| Bind mode | loopback | Only localhost reachable |
---
## 8. Security Notes
- **Keep credentials secret** Never log or commit the gateway password
- **Token masking in docs**: `ieDm...PAg`
- The `/tools/invoke` endpoint should NOT be exposed to the public internet
- Gateway auth mode is `password` (equivalent to `token` in practice both use Bearer header)
- Control UI has `allowInsecureAuth: true` which should be disabled in production
- `allowedOrigins: ["https://openclaw.noveria.net"]` should be reviewed
---
## 9. Example curl Commands (for reference)
```bash
# Health check
curl http://127.0.0.1:18789/health
# Session status
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"session_status","args":{}}'
# List sessions
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"sessions_list","args":{"kinds":["main","subagent"]}}'
# Session history
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"sessions_history","args":{"sessionKey":"iris-main","limit":10}}'
# Memory search
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"memory_search","args":{"query":"nexus","maxResults":5}}'
# Subagents list
curl -s -X POST http://127.0.0.1:18789/tools/invoke \
-H "Authorization: Bearer <PASSWORD>" \
-H "Content-Type: application/json" \
-d '{"tool":"subagents","args":{"action":"list"}}'
```
Replace `<PASSWORD>` with the actual gateway password.
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "default",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/main.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"framework": "vite",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
find /home/node/.openclaw/workspace/nexus/frontend/src -type f \( -name "TeamNetwork*" -o -name "MissionCard*" -o -name "useDashboardData*" \) 2>&1
+13
View File
@@ -11,6 +11,19 @@ server {
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Gehashte Assets: 1 Jahr cachen (immutable wg. Content-Hash im Dateinamen)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA-Entry nie cachen
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location /api/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
+6 -2
View File
@@ -10,10 +10,15 @@
"test": "vitest run"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.8",
"@lucide/vue": "1.17.0",
"@tailwindcss/vite": "^4.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.16",
"vue-router": "^4.5.1"
},
@@ -27,4 +32,3 @@
},
"packageManager": "pnpm@10.12.1"
}
+545
View File
@@ -14,12 +14,27 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.8
version: 4.3.0(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
pinia:
specifier: ^3.0.3
version: 3.0.4(typescript@5.7.3)(vue@3.5.35(typescript@5.7.3))
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.35(typescript@5.7.3))
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
tailwindcss:
specifier: ^4.1.8
version: 4.3.0
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.3.0)
vue:
specifier: ^3.5.16
version: 3.5.35(typescript@5.7.3)
@@ -39,6 +54,9 @@ importers:
vite:
specifier: ^6.3.5
version: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vitest:
specifier: ^3.1.3
version: 3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vue-tsc:
specifier: ^2.2.10
version: 2.2.12(typescript@5.7.3)
@@ -218,6 +236,24 @@ packages:
cpu: [x64]
os: [win32]
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@floating-ui/vue@1.1.11':
resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==}
'@internationalized/date@3.12.2':
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
'@internationalized/number@3.6.7':
resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -364,6 +400,9 @@ packages:
cpu: [x64]
os: [win32]
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@tailwindcss/node@4.3.0':
resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
@@ -454,12 +493,29 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@tanstack/virtual-core@3.17.0':
resolution: {integrity: sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==}
'@tanstack/vue-virtual@3.13.28':
resolution: {integrity: sha512-A+jWpXtMpWXKhGLKQrXeC9mk1VgYeMWSJ+o0CTCEi+HLYMSQFdVmPG9lJz7d4XJyIkc5xVwZU9QY67QpScqnxA==}
peerDependencies:
vue: ^2.7.0 || ^3.0.0
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/node@22.19.20':
resolution: {integrity: sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==}
'@types/web-bluetooth@0.0.20':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -467,6 +523,35 @@ packages:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@vitest/expect@3.2.6':
resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
'@vitest/mocker@3.2.6':
resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
peerDependencies:
msw: ^2.4.9
vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@3.2.6':
resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
'@vitest/runner@3.2.6':
resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
'@vitest/snapshot@3.2.6':
resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
'@vitest/spy@3.2.6':
resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
'@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
@@ -528,9 +613,26 @@ packages:
'@vue/shared@3.5.35':
resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==}
'@vueuse/core@10.11.1':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/metadata@10.11.1':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/shared@10.11.1':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -540,6 +642,25 @@ packages:
brace-expansion@2.1.1:
resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
check-error@2.1.3:
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
engines: {node: '>= 16'}
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
copy-anything@4.0.5:
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
engines: {node: '>=18'}
@@ -550,6 +671,22 @@ packages:
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
defu@6.1.7:
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -562,6 +699,9 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
esbuild@0.25.12:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'}
@@ -570,6 +710,16 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -602,6 +752,9 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -672,6 +825,9 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -682,6 +838,9 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
@@ -690,9 +849,21 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.11:
resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==}
engines: {node: ^18 || >=20}
hasBin: true
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pathval@2.0.1:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
@@ -716,6 +887,11 @@ packages:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
radix-vue@1.9.17:
resolution: {integrity: sha512-mVCu7I2vXt1L2IUYHTt0sZMz7s1K2ZtqKeTIxG3yC5mMFfLBG4FtE1FDeRMpDd+Hhg/ybi9+iXmAP1ISREndoQ==}
peerDependencies:
vue: '>= 3.2.0'
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
@@ -724,6 +900,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -732,10 +911,27 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
@@ -743,10 +939,31 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
tinypool@1.1.1:
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
engines: {node: ^18.0.0 || >=20.0.0}
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
tinyspy@4.0.4:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.7.3:
resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==}
engines: {node: '>=14.17'}
@@ -755,6 +972,11 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite@6.4.3:
resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -795,9 +1017,48 @@ packages:
yaml:
optional: true
vitest@3.2.6:
resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@types/debug': ^4.1.12
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
'@vitest/browser': 3.2.6
'@vitest/ui': 3.2.6
happy-dom: '*'
jsdom: '*'
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@types/debug':
optional: true
'@types/node':
optional: true
'@vitest/browser':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
@@ -817,6 +1078,11 @@ packages:
typescript:
optional: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
@@ -910,6 +1176,34 @@ snapshots:
'@esbuild/win32-x64@0.25.12':
optional: true
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/utils@0.2.11': {}
'@floating-ui/vue@1.1.11(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/utils': 0.2.11
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@internationalized/date@3.12.2':
dependencies:
'@swc/helpers': 0.5.23
'@internationalized/number@3.6.7':
dependencies:
'@swc/helpers': 0.5.23
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1008,6 +1302,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.61.1':
optional: true
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.3.0':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -1076,17 +1374,75 @@ snapshots:
tailwindcss: 4.3.0
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
'@tanstack/virtual-core@3.17.0': {}
'@tanstack/vue-virtual@3.13.28(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@tanstack/virtual-core': 3.17.0
vue: 3.5.35(typescript@5.7.3)
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.9': {}
'@types/node@22.19.20':
dependencies:
undici-types: 6.21.0
'@types/web-bluetooth@0.0.20': {}
'@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.35(typescript@5.7.3))':
dependencies:
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vue: 3.5.35(typescript@5.7.3)
'@vitest/expect@3.2.6':
dependencies:
'@types/chai': 5.2.3
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))':
dependencies:
'@vitest/spy': 3.2.6
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
'@vitest/pretty-format@3.2.6':
dependencies:
tinyrainbow: 2.0.0
'@vitest/runner@3.2.6':
dependencies:
'@vitest/utils': 3.2.6
pathe: 2.0.3
strip-literal: 3.1.0
'@vitest/snapshot@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.6
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@3.2.6':
dependencies:
tinyspy: 4.0.4
'@vitest/utils@3.2.6':
dependencies:
'@vitest/pretty-format': 3.2.6
loupe: 3.2.1
tinyrainbow: 2.0.0
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
@@ -1191,8 +1547,33 @@ snapshots:
'@vue/shared@3.5.35': {}
'@vueuse/core@10.11.1(vue@3.5.35(typescript@5.7.3))':
dependencies:
'@types/web-bluetooth': 0.0.20
'@vueuse/metadata': 10.11.1
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@10.11.1': {}
'@vueuse/shared@10.11.1(vue@3.5.35(typescript@5.7.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.35(typescript@5.7.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
alien-signals@1.0.13: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
assertion-error@2.0.1: {}
balanced-match@1.0.2: {}
birpc@2.9.0: {}
@@ -1201,6 +1582,24 @@ snapshots:
dependencies:
balanced-match: 1.0.2
cac@6.7.14: {}
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
check-error: 2.1.3
deep-eql: 5.0.2
loupe: 3.2.1
pathval: 2.0.1
check-error@2.1.3: {}
class-variance-authority@0.7.1:
dependencies:
clsx: 2.1.1
clsx@2.1.1: {}
copy-anything@4.0.5:
dependencies:
is-what: 5.5.0
@@ -1209,6 +1608,14 @@ snapshots:
de-indent@1.0.2: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
deep-eql@5.0.2: {}
defu@6.1.7: {}
detect-libc@2.1.2: {}
enhanced-resolve@5.23.0:
@@ -1218,6 +1625,8 @@ snapshots:
entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.25.12:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12
@@ -1249,6 +1658,14 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
expect-type@1.3.0: {}
fast-deep-equal@3.1.3: {}
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -1266,6 +1683,8 @@ snapshots:
jiti@2.7.0: {}
js-tokens@9.0.1: {}
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1315,6 +1734,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
loupe@3.2.1: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1325,12 +1746,20 @@ snapshots:
mitt@3.0.1: {}
ms@2.1.3: {}
muggle-string@0.4.1: {}
nanoid@3.3.12: {}
nanoid@5.1.11: {}
path-browserify@1.0.1: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
@@ -1350,6 +1779,23 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
radix-vue@1.9.17(vue@3.5.35(typescript@5.7.3)):
dependencies:
'@floating-ui/dom': 1.7.6
'@floating-ui/vue': 1.1.11(vue@3.5.35(typescript@5.7.3))
'@internationalized/date': 3.12.2
'@internationalized/number': 3.6.7
'@tanstack/vue-virtual': 3.13.28(vue@3.5.35(typescript@5.7.3))
'@vueuse/core': 10.11.1(vue@3.5.35(typescript@5.7.3))
'@vueuse/shared': 10.11.1(vue@3.5.35(typescript@5.7.3))
aria-hidden: 1.2.6
defu: 6.1.7
fast-deep-equal: 3.1.3
nanoid: 5.1.11
vue: 3.5.35(typescript@5.7.3)
transitivePeerDependencies:
- '@vue/composition-api'
rfdc@1.4.1: {}
rollup@4.61.1:
@@ -1383,27 +1829,76 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.61.1
fsevents: 2.3.3
siginfo@2.0.0: {}
source-map-js@1.2.1: {}
speakingurl@14.0.1: {}
stackback@0.0.2: {}
std-env@3.10.0: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
superjson@2.2.6:
dependencies:
copy-anything: 4.0.5
tailwind-merge@3.6.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.3.0):
dependencies:
tailwindcss: 4.3.0
tailwindcss@4.3.0: {}
tapable@2.3.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinypool@1.1.1: {}
tinyrainbow@2.0.0: {}
tinyspy@4.0.4: {}
tslib@2.8.1: {}
typescript@5.7.3: {}
undici-types@6.21.0: {}
vite-node@3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
transitivePeerDependencies:
- '@types/node'
- jiti
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
esbuild: 0.25.12
@@ -1418,8 +1913,53 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.32.0
vitest@3.2.6(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.6
'@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0))
'@vitest/pretty-format': 3.2.6
'@vitest/runner': 3.2.6
'@vitest/snapshot': 3.2.6
'@vitest/spy': 3.2.6
'@vitest/utils': 3.2.6
chai: 5.3.3
debug: 4.4.3
expect-type: 1.3.0
magic-string: 0.30.21
pathe: 2.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 0.3.2
tinyglobby: 0.2.17
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 6.4.3(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
vite-node: 3.2.4(@types/node@22.19.20)(jiti@2.7.0)(lightningcss@1.32.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.20
transitivePeerDependencies:
- jiti
- less
- lightningcss
- msw
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- yaml
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.35(typescript@5.7.3)):
dependencies:
vue: 3.5.35(typescript@5.7.3)
vue-router@4.6.4(vue@3.5.35(typescript@5.7.3)):
dependencies:
'@vue/devtools-api': 6.6.4
@@ -1440,3 +1980,8 @@ snapshots:
'@vue/shared': 3.5.35
optionalDependencies:
typescript: 5.7.3
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
+7 -27
View File
@@ -7,6 +7,7 @@ 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()
@@ -82,32 +83,11 @@ onMounted(() => {
</template>
</section>
</main>
<ToastContainer />
</div>
</template>
<style scoped>
:root {
--bg: #0b0d13;
--panel: #11141b;
--line: #1f2330;
--accent: #7b6ef2;
--accent-soft: rgba(123,110,242,.08);
--text: #e8eaf0;
--text-dim: #6f7889;
--green: #27ae60;
--red: #e74c3c;
--yellow: #f1c40f;
--orange: #e67e22;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { font-size: 15px; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.shell {
display: flex;
height: 100vh;
@@ -135,12 +115,12 @@ main {
gap: 12px;
}
.page-heading h1 { margin: 0; font-size: 18px; }
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--text-dim); }
.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(--accent);
color: var(--nx-accent);
text-transform: uppercase;
}
.refresh {
@@ -149,15 +129,15 @@ main {
gap: 5px;
flex-shrink: 0;
padding: 6px 11px;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
border-radius: 6px;
background: transparent;
color: var(--text-dim);
color: var(--nx-text-dim);
font-size: 9px;
cursor: pointer;
transition: background .15s;
}
.refresh:hover { background: var(--accent-soft); color: #d8dbe3; }
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
+681
View File
@@ -0,0 +1,681 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 252 80% 74%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 252 80% 74%;
--radius: 0.5rem;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
* {
border-color: hsl(var(--border));
}
body {
background: hsl(var(--background));
color: hsl(var(--foreground));
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', sans-serif;
margin: 0;
min-width: 320px;
min-height: 100vh;
}
/* Nexus overrides for existing CSS variables used in dashboard */
:root {
--nx-bg: #080a0f;
--nx-panel: #10131a;
--nx-panel-soft: #0d1016;
--nx-line: #202530;
--nx-muted: #7e8799;
--nx-accent: #8b7cf6;
--nx-accent-soft: rgba(139, 124, 246, 0.12);
--nx-green: #51d49a;
--nx-text: #e8eaf0;
--nx-text-dim: #6f7889;
}
/* ── Existing Nexus layout styles ── */
*,
*::before,
*::after {
box-sizing: border-box;
}
.shell {
min-height: 100vh;
display: grid;
grid-template-columns: 224px 1fr;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
display: flex;
flex-direction: column;
padding: 22px 14px 14px;
border-right: 1px solid #1a1e27;
background: rgba(9, 11, 16, 0.94);
backdrop-filter: blur(18px);
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 0 8px 25px;
}
.brand-mark {
width: 35px;
height: 35px;
display: grid;
place-items: center;
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-size: 13px;
letter-spacing: 0.14em;
}
.brand span,
.owner span {
display: block;
color: var(--nx-muted);
font-size: 10px;
margin-top: 2px;
}
.nav {
display: flex;
flex-direction: column;
gap: 3px;
}
.nav button,
.sidebar-bottom > button {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
border: 0;
padding: 9px 10px;
border-radius: 7px;
background: transparent;
color: #8991a1;
font-size: 12px;
text-align: left;
cursor: pointer;
}
.nav button:hover,
.nav button.active {
color: #ececf5;
background: var(--nx-accent-soft);
}
.nav button.active {
box-shadow: inset 2px 0 var(--nx-accent);
}
.nav button i {
margin-left: auto;
padding: 1px 6px;
border: 1px solid #343947;
border-radius: 8px;
font-size: 9px;
font-style: normal;
}
.sidebar-bottom {
margin-top: auto;
border-top: 1px solid #1b1f28;
padding-top: 10px;
}
.owner {
display: grid;
grid-template-columns: 31px 1fr auto;
gap: 9px;
align-items: center;
margin-top: 10px;
padding: 10px 8px;
width: 100%;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.owner:hover {
background: var(--nx-accent-soft);
border-radius: 8px;
}
.owner strong {
font-size: 11px;
}
.avatar {
width: 31px;
height: 31px;
display: grid;
place-items: center;
border-radius: 50%;
background: #28243f;
color: #bcb3ff;
font-size: 10px;
}
main {
min-width: 0;
}
.topbar {
height: 62px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30px;
border-bottom: 1px solid #191d25;
background: rgba(8, 10, 15, 0.68);
backdrop-filter: blur(16px);
}
.search {
width: min(390px, 42vw);
display: flex;
align-items: center;
gap: 9px;
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 #2c313d;
border-radius: 4px;
color: #606979;
font-size: 9px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.connection {
display: flex;
gap: 6px;
align-items: center;
font-size: 10px;
color: #8c95a5;
}
.connection.live {
color: var(--nx-green);
}
.connection.preview {
color: #e6b75d;
}
.ask,
.refresh-btn {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 11px;
border: 1px solid #37315e;
border-radius: 7px;
background: #18152a;
color: #c4bbff;
font-size: 10px;
cursor: pointer;
}
.content {
padding: 16px 16px 60px;
}
.page-heading {
display: flex;
justify-content: space-between;
align-items: end;
margin-bottom: 28px;
}
.eyebrow,
.kicker {
color: #7065c8;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.18em;
}
h1 {
margin: 7px 0 5px;
font-size: 27px;
letter-spacing: -0.04em;
}
.page-heading p,
.placeholder p {
margin: 0;
color: var(--nx-muted);
font-size: 11px;
}
.refresh-btn {
border-color: var(--nx-line);
background: var(--nx-panel);
color: #a5adba;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.mobile-menu {
display: none;
border: 0;
background: transparent;
color: #aaa4e7;
}
/* ── Keep existing module/layout styles for non-dashboard pages ── */
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
margin-bottom: 10px;
}
.metrics article,
.panel {
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 {
padding: 16px 17px;
}
.metrics span {
color: #717a8a;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.14em;
}
.metrics strong {
display: block;
margin: 7px 0 5px;
font-size: 24px;
letter-spacing: -0.04em;
}
.metrics small {
color: #687181;
font-size: 9px;
}
.metrics small.up {
color: #55c995;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.panel {
padding: 18px;
min-height: 180px;
}
.span-2 {
grid-column: span 2;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 1px solid #1d222c;
}
.panel-head h2 {
margin: 4px 0 0;
font-size: 13px;
}
.panel-head button {
border: 0;
background: transparent;
color: #8e96a5;
font-size: 9px;
}
.badge {
padding: 4px 8px;
border-radius: 10px;
font-size: 8px;
}
.badge.positive {
color: var(--nx-green);
background: rgba(81, 212, 154, 0.1);
}
.badge.warning {
color: #e7b660;
background: rgba(231, 182, 96, 0.1);
}
.badge.negative {
color: #e16e75;
background: rgba(225, 110, 117, 0.1);
}
.runtime-row {
display: flex;
align-items: center;
gap: 12px;
padding-top: 22px;
}
.runtime-icon {
width: 45px;
height: 45px;
display: grid;
place-items: center;
border-radius: 9px;
color: #ad9fff;
background: var(--nx-accent-soft);
}
.runtime-main strong,
.model strong,
.project strong,
.event strong {
display: block;
font-size: 11px;
}
.runtime-main span,
.model small,
.event small {
display: block;
margin-top: 4px;
color: var(--nx-muted);
font-size: 9px;
}
.pulse-bars {
height: 42px;
display: flex;
align-items: center;
gap: 3px;
margin-left: auto;
}
.pulse-bars i {
width: 3px;
min-height: 5px;
border-radius: 3px;
background: linear-gradient(#927fff, #443b7c);
}
.model {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 9px;
padding: 12px 2px;
border-bottom: 1px solid #1b2029;
}
.model > span:last-child {
color: #687181;
font-size: 8px;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #657083;
}
.status-dot.online {
background: var(--nx-green);
box-shadow: 0 0 7px rgba(81, 212, 154, 0.4);
}
.status-dot.offline {
background: #e16e75;
}
.project {
display: grid;
grid-template-columns: 34px 1fr auto;
align-items: center;
gap: 11px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
}
.project-letter {
width: 31px;
height: 31px;
display: grid;
place-items: center;
border: 1px solid #353047;
border-radius: 7px;
color: #a99cf5;
font-size: 10px;
}
.project-info > div:first-child {
display: flex;
justify-content: space-between;
}
.project-info span {
color: var(--nx-muted);
font-size: 8px;
}
.project b {
color: #838c9c;
font-size: 9px;
}
.progress {
height: 3px;
margin-top: 8px;
overflow: hidden;
border-radius: 4px;
background: #242936;
}
.progress i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #685ac8, #a091ff);
}
.event {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #1b2029;
}
.event > span {
width: 6px;
height: 6px;
margin-top: 4px;
border-radius: 50%;
background: #657083;
}
.event > span.runtime {
background: var(--nx-green);
}
.event > span.deploy {
background: #8b7cf6;
}
.event > span.security {
background: #e5ad52;
}
.placeholder {
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.placeholder svg {
margin-bottom: 18px;
color: #8074d8;
}
.placeholder h2 {
margin: 8px 0;
}
/* Animations */
.animate-spin {
animation: spin 1s linear infinite;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: fixed;
z-index: 20;
left: -240px;
width: 224px;
transition: left 0.2s ease;
}
.sidebar.open {
left: 0;
box-shadow: 20px 0 60px #000;
}
.mobile-menu {
display: block;
}
.topbar {
padding: 0 18px;
}
.metrics {
grid-template-columns: repeat(2, 1fr);
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.span-2 {
grid-column: span 1;
}
}
@media (max-width: 560px) {
.content {
padding: 26px 16px 40px;
}
.search {
display: none;
}
.topbar {
justify-content: space-between;
}
.metrics {
grid-template-columns: 1fr 1fr;
}
.page-heading {
align-items: start;
}
.page-heading p {
max-width: 220px;
line-height: 1.5;
}
.runtime-row {
flex-wrap: wrap;
}
.pulse-bars {
width: 100%;
margin-left: 57px;
}
}
+3 -3
View File
@@ -361,7 +361,7 @@ async function sendMessage() {
opacity: 1;
}
.task-edit-btn:hover {
color: var(--accent);
color: var(--nx-accent);
}
.task-delete-btn {
background: none;
@@ -411,7 +411,7 @@ async function sendMessage() {
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--accent);
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 4px;
@@ -499,7 +499,7 @@ async function sendMessage() {
color: var(--text-secondary);
}
.settings-redirect a {
color: var(--accent);
color: var(--nx-accent);
text-decoration: none;
}
.settings-redirect a:hover {
+211 -166
View File
@@ -1,15 +1,84 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
import { ref, onMounted } from 'vue'
import { X, ExternalLink } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
import { useToast } from '../../composables/useToast'
import Button from '@/components/ui/button/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import Select from '@/components/ui/Select.vue'
defineProps<{
const props = defineProps<{
agent: AgentNodeData
runtime: string
}>()
defineEmits<{
const emit = defineEmits<{
close: []
}>()
const toast = useToast()
interface ModelOption {
id: string
name: string
provider: string
}
const availableModels = ref<ModelOption[]>([])
const selectedModel = ref('')
const currentModel = ref('')
const saving = ref(false)
async function loadModels() {
try {
const res = await fetch('/api/dashboard/models', { credentials: 'include' })
if (res.ok) {
availableModels.value = await res.json()
}
} catch {
// silent
}
}
async function loadCurrentModel() {
try {
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, { credentials: 'include' })
if (res.ok) {
const data = await res.json()
selectedModel.value = data.model
currentModel.value = data.model
}
} catch {
// silent
}
}
async function saveModel() {
saving.value = true
try {
const res = await fetch(`/api/dashboard/agents/${props.agent.id}/model`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: selectedModel.value }),
})
if (res.ok) {
currentModel.value = selectedModel.value
toast.success('Model updated successfully')
} else {
toast.error('Failed to update model')
}
} catch {
toast.error('Connection error')
} finally {
saving.value = false
}
}
onMounted(async () => {
await loadModels()
await loadCurrentModel()
})
</script>
<template>
@@ -25,20 +94,59 @@ defineEmits<{
<div>
<h2>{{ agent.name }}</h2>
<span class="modal-role">{{ agent.role }}</span>
<a :href="`/agents/${agent.id}`" class="agent-link-btn" title="Open agent config">
<ExternalLink :size="12" />
</a>
</div>
</div>
<button class="modal-close-btn" @click="$emit('close')" aria-label="Close">
<Button variant="ghost" size="icon" class="h-8 w-8" @click="$emit('close')" aria-label="Close">
<X :size="16" />
</button>
</Button>
</div>
<!-- Status -->
<section class="modal-section">
<h3 class="section-label">Status</h3>
<div class="status-row">
<Badge
:variant="agent.active ? 'default' : 'secondary'"
:class="agent.active ? 'bg-[rgba(81,212,154,0.1)] text-[#51d49a] border-[rgba(81,212,154,0.3)]' : 'bg-[rgba(107,115,133,0.08)] text-[#6b7385] border-[rgba(107,115,133,0.2)]'"
>
<span class="status-dot" :class="{ active: agent.active }" />
{{ agent.active ? 'Active' : 'Idle' }}
</Badge>
<span v-if="agent.active" class="footer-badge">Runtime: {{ runtime }}</span>
</div>
</section>
<!-- Description -->
<p class="modal-desc">{{ agent.description }}</p>
<!-- Tags -->
<section class="modal-section">
<h3 class="section-label">Tags</h3>
<div class="modal-tags-row">
<Badge
v-for="tag in agent.tags"
:key="tag"
variant="outline"
:style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }"
>
{{ tag }}
</Badge>
</div>
</section>
<!-- Current Task -->
<section class="modal-section">
<h3 class="section-label">Current Task</h3>
<p class="section-value">{{ agent.currentTask }}</p>
<p class="section-value">
{{ agent.currentTask }}
<span v-if="agent.active" class="thinking-dots">
<span class="thinking-dot blue"></span>
<span class="thinking-dot violet"></span>
</span>
</p>
</section>
<!-- Goal + Progress -->
@@ -48,66 +156,38 @@ defineEmits<{
<div class="progress-row">
<span class="progress-pct">{{ agent.progress }}%</span>
<div class="progress-track">
<div
class="progress-fill"
:style="{ width: `${agent.progress}%` }"
></div>
<div class="progress-fill" :style="{ width: `${agent.progress}%` }"></div>
</div>
</div>
</section>
<!-- Working Feed -->
<!-- Model -->
<section class="modal-section">
<h3 class="section-label">Working Feed</h3>
<div class="work-feed">
<div
v-for="(step, idx) in agent.workingFeed"
:key="idx"
class="work-step"
<h3 class="section-label">Model</h3>
<div class="model-select-row">
<Select v-model="selectedModel" class="flex-1 text-xs border-[#a78bfa]">
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.name }} ({{ m.provider }})
</option>
</Select>
<Button
variant="default"
size="sm"
class="bg-[#a78bfa] hover:bg-[#c4b5fd]"
:disabled="saving || selectedModel === currentModel"
@click="saveModel"
>
<span class="step-dot"></span>
<span class="step-text">{{ step }}</span>
</div>
</div>
</section>
<!-- Live Thinking -->
<section class="modal-section">
<h3 class="section-label">Live Thinking</h3>
<div class="thinking-panel">
<div class="thinking-orbiter">
<span class="orbiter-dot dot-blue"></span>
<span class="orbiter-dot dot-violet"></span>
</div>
<div class="thinking-stream" ref="thinkingStreamRef">
<div
v-for="(msg, idx) in agent.thinkingStream"
:key="idx"
class="thinking-entry"
:style="{ animationDelay: `${idx * 0.05}s` }"
>
<span class="entry-time">{{ msg.time }}</span>
<span class="entry-text">{{ msg.text }}</span>
</div>
<div v-if="!agent.thinkingStream?.length" class="thinking-placeholder">
Waiting for thought stream...
</div>
</div>
{{ saving ? 'Saving...' : 'Save' }}
</Button>
</div>
</section>
<!-- Footer Stats -->
<div class="modal-footer">
<span class="footer-badge">Runtime: {{ runtime }}</span>
<span
class="footer-badge"
:style="{
color: agent.workload > 65 ? '#eab308' : '#22c55e',
borderColor: agent.workload > 65 ? 'rgba(234,179,8,0.2)' : 'rgba(34,197,94,0.2)',
}"
>
Workload: {{ agent.workload }}%
</span>
<Badge :class="agent.active ? 'bg-[rgba(81,212,154,0.06)] text-[#51d49a] border-[rgba(81,212,154,0.25)]' : 'bg-[rgba(107,115,133,0.04)] text-[#6b7385] border-[rgba(107,115,133,0.15)]'">
{{ agent.active ? '● Active' : '○ Idle' }}
</Badge>
<span v-if="agent.active" class="footer-badge">{{ runtime }}</span>
</div>
</div>
</div>
@@ -193,22 +273,27 @@ defineEmits<{
color: #6b7385;
font-weight: 500;
}
.modal-close-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
.agent-link-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-left: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
opacity: 0.4;
cursor: pointer;
transition: all 0.2s;
transition: opacity 0.2s;
flex-shrink: 0;
text-decoration: none;
vertical-align: middle;
}
.modal-close-btn:hover {
border-color: rgba(255, 255, 255, 0.15);
color: #e8eaf0;
.agent-link-btn:hover {
opacity: 1;
color: var(--agent-color);
}
.modal-desc {
@@ -236,6 +321,39 @@ defineEmits<{
font-size: 12px;
color: #e8eaf0;
line-height: 1.4;
display: flex;
align-items: center;
gap: 8px;
}
/* Status */
.status-row {
display: flex;
align-items: center;
gap: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #6b7385;
transition: all 0.3s ease;
}
.status-dot.active {
background: #51d49a;
box-shadow: 0 0 8px rgba(81, 212, 154, 0.6);
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(1.2); }
}
/* Tags */
.modal-tags-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* Progress */
@@ -266,114 +384,34 @@ defineEmits<{
transition: width 0.5s ease;
}
/* Live Thinking */
.thinking-panel {
position: relative;
border: 1px solid rgba(139, 124, 246, 0.2);
border-radius: 12px;
padding: 14px;
background: rgba(12, 16, 22, 0.6);
overflow: hidden;
animation: panel-pulse 2.5s ease-in-out infinite;
/* Thinking Dots */
.thinking-dots {
display: inline-flex;
gap: 6px;
flex-shrink: 0;
}
@keyframes panel-pulse {
0%, 100% { border-color: rgba(139, 124, 246, 0.2); box-shadow: 0 0 8px rgba(139,124,246,0.05); }
50% { border-color: rgba(139, 124, 246, 0.35); box-shadow: 0 0 16px rgba(139,124,246,0.1); }
}
.thinking-orbiter {
position: absolute;
inset: -8px;
pointer-events: none;
z-index: 0;
animation: spin-orbiter 4s linear infinite;
}
@keyframes spin-orbiter {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.orbiter-dot {
position: absolute;
width: 10px;
height: 10px;
.thinking-dot {
width: 7px;
height: 7px;
border-radius: 50%;
filter: blur(1px);
}
.dot-blue {
top: 0;
left: 50%;
transform: translate(-50%, -50%);
.thinking-dot.blue {
background: #3b82f6;
box-shadow: 0 0 10px #3b82f6, 0 0 20px rgba(59,130,246,0.5);
box-shadow: 0 0 8px #3b82f6;
animation: pulse-dot-blue 1.2s ease-in-out infinite;
}
.dot-violet {
bottom: 0;
left: 50%;
transform: translate(-50%, 50%);
.thinking-dot.violet {
background: #8b7cf6;
box-shadow: 0 0 10px #8b7cf6, 0 0 20px rgba(139,124,246,0.5);
box-shadow: 0 0 8px #8b7cf6;
animation: pulse-dot-violet 1.8s ease-in-out infinite 0.3s;
}
.thinking-stream {
max-height: 160px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
z-index: 1;
@keyframes pulse-dot-blue {
0%, 100% { opacity: 0.4; transform: scale(0.7); }
50% { opacity: 1; transform: scale(1.3); }
}
.thinking-entry {
display: flex;
gap: 10px;
align-items: baseline;
animation: slide-in-right 0.3s ease-out both;
font-size: 10px;
}
@keyframes slide-in-right {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
.entry-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
min-width: 42px;
}
.entry-text {
color: #9ea5b3;
line-height: 1.4;
}
.thinking-placeholder {
font-size: 10px;
color: #4a5160;
font-style: italic;
}
/* Working Feed */
.work-feed {
display: flex;
flex-direction: column;
gap: 6px;
}
.work-step {
display: flex;
align-items: center;
gap: 8px;
}
.step-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--agent-color);
flex-shrink: 0;
opacity: 0.5;
}
.step-text {
font-size: 10.5px;
color: #7e8799;
line-height: 1.35;
@keyframes pulse-dot-violet {
0%, 100% { opacity: 0.3; transform: scale(0.6); }
50% { opacity: 1; transform: scale(1.4); }
}
/* Footer */
@@ -393,4 +431,11 @@ defineEmits<{
border: 1px solid rgba(255, 255, 255, 0.06);
color: #7e8799;
}
/* Model Selector */
.model-select-row {
display: flex;
gap: 8px;
align-items: center;
}
</style>
@@ -1,208 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Code2, Server, Search, Shield, Bot } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
const props = defineProps<{
agent: AgentNodeData
runtime: string
}>()
const emit = defineEmits<{
select: [agentId: string]
}>()
const iconComponent = computed(() => {
switch (props.agent.icon) {
case 'code': return Code2
case 'server': return Server
case 'search': return Search
case 'shield': return Shield
default: return Bot
}
})
/* Workload Ring */
const R = 18
const STROKE = 3
const CIRCUMFERENCE = 2 * Math.PI * R
const workloadOffset = computed(() => {
const pct = Math.min(props.agent.workload, 100)
return CIRCUMFERENCE - (CIRCUMFERENCE * pct) / 100
})
const workloadRingColor = computed(() => {
const w = props.agent.workload
if (w < 40) return '#22c55e'
if (w < 65) return '#eab308'
if (w < 85) return '#f97316'
return '#ef4444'
})
const cardClass = computed(() =>
props.agent.active ? 'agent-card pulse-active' : 'agent-card node-idle'
)
</script>
<template>
<article
:class="cardClass"
:style="{ '--agent-color': agent.color }"
@click="emit('select', agent.id)"
tabindex="0"
@keyup.enter="emit('select', agent.id)"
role="button"
>
<!-- Workload Ring -->
<svg class="wl-ring" viewBox="0 0 44 44" width="44" height="44">
<circle
cx="22" cy="22" :r="R"
fill="none"
stroke="rgba(255,255,255,0.05)"
:stroke-width="STROKE"
/>
<circle
cx="22" cy="22" :r="R"
fill="none"
:stroke="workloadRingColor"
:stroke-width="STROKE"
stroke-linecap="round"
:stroke-dasharray="CIRCUMFERENCE"
:stroke-dashoffset="workloadOffset"
transform="rotate(-90 22 22)"
class="ring-fill"
/>
</svg>
<!-- Icon -->
<div class="node-icon" :style="{ background: `${agent.color}18`, color: agent.color }">
<component :is="iconComponent" :size="18" />
</div>
<!-- Info -->
<div class="node-info">
<div class="node-name-row">
<h3 class="node-name">{{ agent.name }}</h3>
<span
class="node-status-dot"
:class="{ active: agent.active }"
:style="{ background: agent.active ? agent.color : '#6b7385' }"
></span>
</div>
<span class="node-role">{{ agent.role }}</span>
<span class="node-task" :title="agent.currentTask">{{ agent.currentTask }}</span>
</div>
<!-- Runtime -->
<div class="node-runtime">{{ runtime }}</div>
</article>
</template>
<style scoped>
.agent-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid color-mix(in srgb, var(--agent-color) 15%, transparent);
border-radius: 14px;
cursor: pointer;
transition: all 0.25s ease;
position: relative;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.agent-card:hover {
border-color: color-mix(in srgb, var(--agent-color) 40%, transparent);
box-shadow: 0 0 24px color-mix(in srgb, var(--agent-color) 8%, transparent);
transform: translateY(-1px);
}
.agent-card:focus-visible {
outline: 2px solid var(--agent-color);
outline-offset: 2px;
}
.node-idle {
opacity: 0.6;
}
/* Workload SVG Ring */
.wl-ring {
flex-shrink: 0;
}
.ring-fill {
transition: stroke-dashoffset 0.6s ease, stroke 0.3s ease;
}
/* Icon */
.node-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 10px;
flex-shrink: 0;
}
/* Info */
.node-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.node-name-row {
display: flex;
align-items: center;
gap: 6px;
}
.node-name {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #e8eaf0;
}
.node-status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: all 0.3s ease;
flex-shrink: 0;
}
.node-status-dot.active {
box-shadow: 0 0 6px currentColor;
}
.node-role {
font-size: 9px;
color: #6b7385;
font-weight: 500;
}
.node-task {
font-size: 9.5px;
color: #7e8799;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 160px;
}
.node-runtime {
font-size: 10px;
font-variant-numeric: tabular-nums;
color: #6b7385;
flex-shrink: 0;
font-weight: 600;
min-width: 40px;
text-align: right;
}
/* Pulse active dot */
.pulse-active .node-status-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.4); }
}
</style>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { Bot } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData'
import { renderMarkdown } from '../../utils/markdown'
defineProps<{
messages: ChatMessage[]
irisBusy: boolean
elapsedSeconds: number
}>()
function formatTime(ts: number): string {
const d = new Date(ts)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
</script>
<template>
<div class="flex flex-col gap-2.5">
<div
v-for="msg in messages"
:key="msg.id"
:class="['flex gap-2', msg.sender === 'user' ? 'justify-end' : '']"
>
<div v-if="msg.sender === 'iris'" class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
<Bot :size="12" />
</div>
<div
:class="[
'px-3 py-2 rounded-lg text-[10.5px] leading-[1.45] max-w-[85%]',
msg.sender === 'iris'
? 'bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.1)] text-[#d4d8e0]'
: 'bg-[rgba(59,130,246,0.12)] border border-[rgba(59,130,246,0.15)] text-[#e8eaf0]',
]"
>
<div v-if="msg.sender === 'iris'" v-html="renderMarkdown(msg.text)" class="msg-md"></div>
<p v-else class="m-0">{{ msg.text }}</p>
<div
:class="[
'text-[8px] mt-1 opacity-50',
msg.sender === 'user' ? 'text-right' : 'text-left',
]"
>
{{ formatTime(msg.timestamp) }}
</div>
</div>
</div>
<!-- Busy Bubble -->
<div v-if="irisBusy" class="flex gap-2 max-w-[75%]">
<div class="flex-shrink-0 self-end w-6 h-6 grid place-items-center rounded-lg bg-[rgba(167,139,250,0.15)] text-[#a78bfa]">
<Bot :size="12" />
</div>
<div class="px-3 py-2 rounded-lg text-[10.5px] bg-[rgba(167,139,250,0.08)] border border-[rgba(167,139,250,0.18)] text-[#c4c8d4]">
<div class="flex items-center gap-2">
<span class="w-[7px] h-[7px] rounded-full bg-[#a78bfa] animate-pulse flex-shrink-0" />
<span>Denkt nach...</span>
</div>
<div class="text-[8px] mt-1 opacity-50">läuft seit {{ elapsedSeconds }}s</div>
</div>
</div>
<div v-if="messages.length === 0" class="flex-1 grid place-items-center text-center py-8">
<p class="text-[10px] text-[#6b7385] max-w-[180px] leading-[1.4] m-0">
No messages yet. Start a conversation with Iris.
</p>
</div>
</div>
</template>
<style scoped>
.msg-md { line-height: 1.55; }
.msg-md :deep(p) { margin: 0 0 6px 0; }
.msg-md :deep(p:last-child) { margin-bottom: 0; }
.msg-md :deep(strong) { color: #e8eaf0; font-weight: 700; }
.msg-md :deep(em) { font-style: italic; color: #c4c8d4; }
.msg-md :deep(code) { background: rgba(0,0,0,0.3); padding: 1px 5px; border-radius: 4px; font-family: 'JetBrains Mono','Fira Code',monospace; font-size: 10px; color: #f472b6; }
.msg-md :deep(pre) { background: rgba(0,0,0,0.35); padding: 8px 10px; border-radius: 8px; overflow-x: auto; margin: 6px 0; border: 1px solid rgba(255,255,255,0.04); }
.msg-md :deep(pre code) { background: none; padding: 0; font-size: 10px; color: #d4d8e0; }
.msg-md :deep(a) { color: #a78bfa; text-decoration: underline; text-underline-offset: 2px; }
.msg-md :deep(a:hover) { color: #c4b5fd; }
.msg-md :deep(ul) { margin: 4px 0; padding-left: 16px; }
.msg-md :deep(li) { margin: 2px 0; }
.msg-md :deep(h1), .msg-md :deep(h2), .msg-md :deep(h3), .msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { margin: 8px 0 4px 0; color: #e8eaf0; font-weight: 700; line-height: 1.3; }
.msg-md :deep(h1) { font-size: 13px; }
.msg-md :deep(h2) { font-size: 12px; }
.msg-md :deep(h3) { font-size: 11px; }
.msg-md :deep(h4), .msg-md :deep(h5), .msg-md :deep(h6) { font-size: 10.5px; }
.msg-md :deep(hr) { border: none; border-top: 1px solid rgba(255,255,255,0.08); margin: 8px 0; }
</style>
+139 -144
View File
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { Bot, Send, LoaderCircle } from '@lucide/vue'
import { ref, nextTick, watch, onUnmounted } from 'vue'
import { Bot, Send, Maximize2 } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData'
import { useDashboardData } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
import Textarea from '@/components/ui/Textarea.vue'
import Dialog from '@/components/ui/Dialog.vue'
import DialogHeader from '@/components/ui/DialogHeader.vue'
import DialogTitle from '@/components/ui/DialogTitle.vue'
import ChatMessageList from './ChatMessageList.vue'
const props = defineProps<{
messages: ChatMessage[]
@@ -9,16 +16,50 @@ const props = defineProps<{
irisFocus: string
}>()
const emit = defineEmits<{
send: [text: string]
}>()
const { sendChatMessage, busySince } = useDashboardData()
const elapsedSeconds = ref(0)
let elapsedInterval: ReturnType<typeof setInterval> | null = null
function startElapsedTimer(): void {
stopElapsedTimer()
const update = () => {
if (busySince.value > 0) {
elapsedSeconds.value = Math.floor((Date.now() - busySince.value) / 1000)
}
}
update()
elapsedInterval = setInterval(update, 1000)
}
function stopElapsedTimer(): void {
if (elapsedInterval) {
clearInterval(elapsedInterval)
elapsedInterval = null
}
}
watch(() => props.irisBusy, (busy) => {
if (busy) {
startElapsedTimer()
} else {
stopElapsedTimer()
elapsedSeconds.value = 0
}
}, { immediate: true })
onUnmounted(() => {
stopElapsedTimer()
})
const inputText = ref('')
const chatListRef = ref<HTMLElement | null>(null)
const chatModalListRef = ref<HTMLElement | null>(null)
const dialogOpen = ref(false)
function sendMessage(): void {
if (!inputText.value.trim()) return
emit('send', inputText.value)
sendChatMessage(inputText.value)
inputText.value = ''
}
@@ -26,24 +67,25 @@ watch(
() => props.messages.length,
async () => {
await nextTick()
if (chatListRef.value) {
chatListRef.value.scrollTop = chatListRef.value.scrollHeight
const el = dialogOpen.value ? chatModalListRef.value : chatListRef.value
if (el) {
el.scrollTop = el.scrollHeight
}
}
)
</script>
<template>
<!-- Inline Chat Panel -->
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<Bot :size="16" class="chat-header-icon" />
<Bot :size="16" class="text-[#a78bfa]" />
<h2>Iris Chat</h2>
</div>
<div v-if="irisBusy" class="busy-badge">
<LoaderCircle :size="10" class="spin" />
<span>Busy</span>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = true" title="Open larger chat">
<Maximize2 :size="14" />
</Button>
</div>
<!-- Focus Bar -->
@@ -54,42 +96,80 @@ watch(
<!-- Messages -->
<div ref="chatListRef" class="chat-messages">
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg-row', msg.sender === 'user' ? 'msg-user' : 'msg-iris']"
>
<div v-if="msg.sender === 'iris'" class="msg-avatar">
<Bot :size="12" />
</div>
<div class="msg-bubble">
<p>{{ msg.text }}</p>
</div>
</div>
<div v-if="messages.length === 0" class="empty-state">
<p>No messages yet. Start a conversation with Iris.</p>
</div>
<ChatMessageList
:messages="messages"
:iris-busy="irisBusy"
:elapsed-seconds="elapsedSeconds"
/>
</div>
<!-- Input -->
<div class="chat-input-row">
<input
<Textarea
v-model="inputText"
type="text"
rows="1"
placeholder="Type a message..."
@keyup.enter="sendMessage"
class="min-h-0 h-9 resize-none text-xs bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385] text-[10px]"
@keyup.enter.exact="sendMessage"
/>
<button
class="send-btn"
<Button
size="icon"
class="h-8 w-8 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="14" />
</button>
</Button>
</div>
</div>
<!-- Expanded Chat Dialog -->
<Dialog :open="dialogOpen" class="sm:max-w-[820px] sm:h-[78vh] p-0 gap-0" @update:open="dialogOpen = $event">
<template #default>
<DialogHeader class="flex-row items-center justify-between px-5 py-4 border-b border-[rgba(255,255,255,0.06)]">
<div class="flex items-center gap-2">
<Bot :size="18" class="text-[#a78bfa]" />
<DialogTitle>Iris Chat</DialogTitle>
</div>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="dialogOpen = false" aria-label="Close">
<span class="text-lg leading-none">&times;</span>
</Button>
</DialogHeader>
<div v-if="irisBusy && irisFocus" class="focus-bar !px-5">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<div ref="chatModalListRef" class="flex-1 overflow-y-auto px-5 py-4">
<ChatMessageList
:messages="messages"
:iris-busy="irisBusy"
:elapsed-seconds="elapsedSeconds"
/>
</div>
<div class="flex gap-2 px-4 py-3 border-t border-[rgba(255,255,255,0.06)]">
<Textarea
v-model="inputText"
rows="1"
placeholder="Type a message..."
class="min-h-0 h-10 resize-none text-sm bg-black/30 border-[rgba(255,255,255,0.08)] text-[#e8eaf0] placeholder:text-[#6b7385]"
@keyup.enter.exact="sendMessage"
/>
<Button
size="icon"
class="h-10 w-10 bg-[#a78bfa] hover:bg-[#c4b5fd] flex-shrink-0"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="18" />
</Button>
</div>
</template>
</Dialog>
</template>
<style scoped>
@@ -124,33 +204,12 @@ watch(
align-items: center;
gap: 8px;
}
.chat-header-icon {
color: #a78bfa;
}
.chat-header h2 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e8eaf0;
}
.busy-badge {
display: flex;
align-items: center;
gap: 5px;
font-size: 9px;
font-weight: 600;
color: #eab308;
padding: 3px 10px;
border-radius: 20px;
background: rgba(234, 179, 8, 0.08);
border: 1px solid rgba(234, 179, 8, 0.15);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Focus Bar */
.focus-bar {
@@ -179,9 +238,6 @@ watch(
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-messages::-webkit-scrollbar {
width: 5px;
@@ -194,59 +250,6 @@ watch(
border-radius: 3px;
}
.msg-row {
display: flex;
gap: 8px;
max-width: 85%;
}
.msg-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.msg-avatar {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
flex-shrink: 0;
align-self: flex-end;
}
.msg-bubble {
padding: 8px 12px;
border-radius: 10px;
font-size: 10.5px;
line-height: 1.45;
}
.msg-iris .msg-bubble {
background: rgba(167, 139, 250, 0.08);
border: 1px solid rgba(167, 139, 250, 0.1);
color: #d4d8e0;
}
.msg-user .msg-bubble {
background: rgba(59, 130, 246, 0.12);
border: 1px solid rgba(59, 130, 246, 0.15);
color: #e8eaf0;
}
.msg-bubble p {
margin: 0;
}
.empty-state {
flex: 1;
display: grid;
place-items: center;
text-align: center;
}
.empty-state p {
font-size: 10px;
color: #6b7385;
max-width: 180px;
line-height: 1.4;
}
/* Input */
.chat-input-row {
display: flex;
@@ -254,43 +257,35 @@ watch(
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-input-row input {
flex: 1;
/* ── Mobile: compact mode ── */
@media (max-width: 768px) {
.chat-panel {
min-height: 280px;
max-height: 360px;
border-radius: 12px;
}
.chat-header {
padding: 10px 12px;
}
.chat-header h2 {
font-size: 11px;
}
.chat-messages {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
color: #e8eaf0;
font-size: 10px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
min-width: 0;
}
.chat-input-row input:focus {
border-color: #a78bfa;
.chat-input-row {
padding: 8px 10px;
gap: 4px;
}
.chat-input-row input::placeholder {
color: #6b7385;
.focus-bar {
padding: 6px 12px;
}
.send-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border: none;
border-radius: 8px;
background: #a78bfa;
color: #fff;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.2s;
.focus-label {
font-size: 7px;
}
.send-btn:disabled {
opacity: 0.35;
cursor: default;
.focus-text {
font-size: 9px;
}
.send-btn:not(:disabled):hover {
opacity: 0.85;
}
</style>
@@ -0,0 +1,260 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ChevronLeft, ChevronRight, X } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
const props = defineProps<{
entries: FeedEntry[]
modelValue: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const selectedDayOffset = ref(0) // 0 = today, -1 = yesterday, etc.
function close() {
emit('update:modelValue', false)
}
function dayLabel(offset: number): string {
if (offset === 0) return 'Heute'
if (offset === -1) return 'Gestern'
if (offset === -2) return 'Vorgestern'
const d = new Date()
d.setDate(d.getDate() + offset)
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
}
function navigateDay(dir: -1 | 1) {
const next = selectedDayOffset.value + dir
if (next >= -6 && next <= 0) {
selectedDayOffset.value = next
}
}
const filteredEntries = computed(() => {
const targetDate = new Date()
targetDate.setDate(targetDate.getDate() + selectedDayOffset.value)
const targetStr = targetDate.toISOString().slice(0, 10)
return props.entries.filter(e => e.timestamp.slice(0, 10) === targetStr)
})
</script>
<template>
<Teleport to="body">
<div v-if="modelValue" class="feed-modal-overlay" @click.self="close">
<div class="feed-modal-card">
<div class="feed-modal-header">
<h2 class="feed-modal-title">Operations Log</h2>
<button class="feed-modal-close-btn" @click="close" aria-label="Close">
<X :size="16" />
</button>
</div>
<div class="feed-modal-nav">
<button
class="feed-nav-btn"
:disabled="selectedDayOffset <= -6"
@click="navigateDay(-1)"
aria-label="Previous day"
>
<ChevronLeft :size="14" />
</button>
<span class="feed-nav-label">{{ dayLabel(selectedDayOffset) }}</span>
<button
class="feed-nav-btn"
:disabled="selectedDayOffset >= 0"
@click="navigateDay(1)"
aria-label="Next day"
>
<ChevronRight :size="14" />
</button>
</div>
<div class="feed-modal-entries">
<div v-if="filteredEntries.length === 0" class="feed-modal-empty">
Keine Einträge für diesen Tag.
</div>
<div
v-for="(entry, idx) in filteredEntries"
:key="entry.timestamp + '-' + idx"
class="feed-modal-entry"
>
<span class="feed-time">{{ entry.time }}</span>
<span class="feed-bullet">&middot;</span>
<span class="feed-agent" :class="'agent-' + entry.agent.toLowerCase()">
{{ entry.agent }}
</span>
<span class="feed-action">{{ entry.action }}</span>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.feed-modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
padding: 20px;
animation: feed-overlay-in 0.2s ease;
}
@keyframes feed-overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.feed-modal-card {
background: #161b22;
border: 1px solid rgba(139, 124, 246, 0.15);
border-radius: 16px;
padding: 24px;
width: 100%;
max-width: 520px;
max-height: 80vh;
display: flex;
flex-direction: column;
gap: 16px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
animation: feed-card-in 0.25s ease;
}
@keyframes feed-card-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.feed-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.feed-modal-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #e8eaf0;
}
.feed-modal-close-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: none;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
color: #7e8799;
cursor: pointer;
transition: all 0.15s;
}
.feed-modal-close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #e8eaf0;
}
.feed-modal-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.feed-nav-btn {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid rgba(139, 124, 246, 0.15);
background: rgba(139, 124, 246, 0.08);
border-radius: 8px;
color: #a78bfa;
cursor: pointer;
transition: all 0.15s;
}
.feed-nav-btn:hover:not(:disabled) {
background: rgba(139, 124, 246, 0.16);
border-color: rgba(139, 124, 246, 0.3);
}
.feed-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.feed-nav-label {
font-size: 12px;
font-weight: 600;
color: #d1d5db;
min-width: 100px;
text-align: center;
}
.feed-modal-entries {
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
max-height: 50vh;
padding-right: 4px;
}
.feed-modal-empty {
text-align: center;
padding: 24px 0;
font-size: 11px;
color: #6b7385;
}
.feed-modal-entry {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 6px;
border-radius: 6px;
font-size: 9.5px;
line-height: 1.3;
transition: background 0.15s;
}
.feed-modal-entry:hover {
background: rgba(255, 255, 255, 0.03);
}
.feed-time {
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
width: 32px;
}
.feed-bullet {
color: #6b7385;
flex-shrink: 0;
}
.feed-agent {
font-weight: 600;
flex-shrink: 0;
}
.agent-iris {
color: #a78bfa;
}
.agent-developer {
color: #3b82f6;
}
.agent-devops {
color: #eab308;
}
.agent-researcher {
color: #22c55e;
}
.agent-reviewer {
color: #a855f7;
}
.feed-action {
color: #7e8799;
white-space: normal;
word-break: break-word;
}
</style>
@@ -1,201 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Clock, ChevronRight } from '@lucide/vue'
import type { MissionData } from '../../composables/useDashboardData'
const props = defineProps<{
mission: MissionData
}>()
const statusColor: Record<string, string> = {
healthy: '#22c55e',
attention: '#eab308',
blocked: '#ef4444',
paused: '#6b7280',
}
const statusLabel = computed(() => {
const map: Record<string, string> = {
healthy: 'Healthy',
attention: 'Warning',
blocked: 'Blocked',
paused: 'Paused',
}
return map[props.mission.status] ?? props.mission.status
})
</script>
<template>
<article class="mission-card" tabindex="0">
<div class="mission-head">
<h3>{{ mission.name }}</h3>
<span
class="mission-status"
:style="{ color: statusColor[mission.status] }"
>
{{ statusLabel }}
</span>
</div>
<div class="progress-track">
<div
class="progress-fill"
:style="{
width: `${mission.progress}%`,
background: `linear-gradient(90deg, ${statusColor[mission.status]}, color-mix(in srgb, ${statusColor[mission.status]} 65%, #fff))`,
}"
></div>
</div>
<div class="mission-body">
<div class="mission-detail">
<span class="detail-label">Current Task</span>
<span class="detail-value">{{ mission.currentTask }}</span>
</div>
<div class="mission-footer">
<div class="mission-meta">
<Clock :size="10" />
<span>{{ mission.lastActivity }}</span>
</div>
<div class="mission-tasks">
<span class="tasks-count">{{ mission.remainingTasks }}</span>
<span class="tasks-label">remaining</span>
</div>
</div>
</div>
<div class="mission-arrow">
<ChevronRight :size="14" />
</div>
</article>
</template>
<style scoped>
.mission-card {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
cursor: pointer;
transition: all 0.25s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.mission-card:hover {
border-color: rgba(139, 124, 246, 0.2);
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.mission-card:focus-visible {
outline: 2px solid #a78bfa;
outline-offset: 2px;
}
.mission-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mission-head h3 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #e8eaf0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mission-status {
font-size: 8px;
font-weight: 600;
text-transform: capitalize;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.progress-track {
height: 3px;
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
.mission-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.mission-detail {
display: flex;
flex-direction: column;
gap: 3px;
}
.detail-label {
font-size: 8px;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.detail-value {
font-size: 10px;
color: #7e8799;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mission-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.mission-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #6b7385;
}
.mission-tasks {
display: flex;
align-items: center;
gap: 4px;
}
.tasks-count {
font-size: 12px;
font-weight: 700;
color: #a78bfa;
font-variant-numeric: tabular-nums;
}
.tasks-label {
font-size: 8px;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mission-arrow {
position: absolute;
right: 10px;
bottom: 10px;
color: #6b7385;
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.mission-card:hover .mission-arrow {
opacity: 1;
transform: translateX(2px);
}
</style>
@@ -1,10 +1,22 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Activity } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
import FeedDetailModal from './FeedDetailModal.vue'
defineProps<{
const props = defineProps<{
entries: FeedEntry[]
}>()
// ── Compact feed (5 items) ──
const compactEntries = computed(() => props.entries.slice(0, 5))
// ── Feed Detail Modal ──
const showDetailModal = ref(false)
function openDetailModal() {
showDetailModal.value = true
}
</script>
<template>
@@ -17,7 +29,7 @@ defineProps<{
<div class="feed-list">
<TransitionGroup name="feed">
<div
v-for="(entry, idx) in entries.slice(0, 8)"
v-for="(entry, idx) in compactEntries"
:key="entry.timestamp + '-' + idx"
class="feed-entry"
>
@@ -33,7 +45,17 @@ defineProps<{
<div v-if="entries.length === 0" class="feed-empty">
<span>No operations recorded yet.</span>
</div>
<button v-if="entries.length > 5" class="feed-more-btn" @click="openDetailModal">
Mehr anzeigen
</button>
</div>
<FeedDetailModal
:entries="entries"
:model-value="showDetailModal"
@update:model-value="showDetailModal = $event"
/>
</div>
</template>
@@ -121,9 +143,8 @@ defineProps<{
}
.feed-action {
color: #7e8799;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
}
.feed-empty {
@@ -133,6 +154,26 @@ defineProps<{
color: #6b7385;
}
.feed-more-btn {
display: block;
width: 100%;
padding: 8px;
margin-top: 4px;
background: rgba(139, 124, 246, 0.08);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 8px;
color: #a78bfa;
font-size: 9.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.feed-more-btn:hover {
background: rgba(139, 124, 246, 0.14);
border-color: rgba(139, 124, 246, 0.2);
}
/* TransitionGroup */
.feed-enter-active {
transition: all 0.3s ease;
@@ -10,6 +10,8 @@ import {
Zap,
} from '@lucide/vue'
import type { QueueItem } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
import Badge from '@/components/ui/Badge.vue'
const props = defineProps<{
items: QueueItem[]
@@ -73,16 +75,18 @@ function onDragEnd(): void {
<template>
<div class="queue-panel">
<div class="queue-header" @click="expanded = !expanded">
<div class="queue-header" @click="expanded = !expanded" role="button" tabindex="0" :aria-expanded="expanded" @keyup.enter="expanded = !expanded">
<div class="queue-header-left">
<ListTodo :size="14" class="queue-icon" />
<h2>Queue</h2>
<span class="queue-count">{{ items.length }}</span>
<ListTodo :size="14" class="text-[#a78bfa]" />
<h2>Chat Queue</h2>
<Badge variant="outline" class="text-[10px] font-bold text-[#a78bfa] bg-[rgba(167,139,250,0.1)] border-0 rounded-full px-2">
{{ items.length }}
</Badge>
</div>
<button class="queue-toggle" aria-label="Toggle">
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="expanded ? 'Collapse' : 'Expand'">
<ChevronUp v-if="expanded" :size="14" />
<ChevronDown v-else :size="14" />
</button>
</Button>
</div>
<Transition name="queue-expand">
@@ -103,8 +107,9 @@ function onDragEnd(): void {
>
<div class="queue-item-body">
<div class="queue-item-head">
<span
class="priority-badge"
<Badge
variant="outline"
class="text-[7px] font-bold uppercase tracking-wider py-0 px-1.5 border"
:style="{
color: priorityColor[item.priority],
borderColor: `${priorityColor[item.priority]}30`,
@@ -112,43 +117,25 @@ function onDragEnd(): void {
}"
>
{{ item.priority }}
</span>
</Badge>
<span class="queue-wait">{{ item.waitTime }}</span>
</div>
<p class="queue-text">{{ item.text }}</p>
</div>
<div class="queue-actions">
<button
class="q-action-btn"
title="Execute now"
@click.stop="emit('executeNow', item.id)"
>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Execute now" @click.stop="emit('executeNow', item.id)">
<Zap :size="12" />
</button>
<button
class="q-action-btn"
title="Move up"
:disabled="idx === 0"
@click.stop="emit('moveUp', item.id)"
>
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Move up" :disabled="idx === 0" @click.stop="emit('moveUp', item.id)">
<ArrowUp :size="12" />
</button>
<button
class="q-action-btn"
title="Move down"
:disabled="idx === items.length - 1"
@click.stop="emit('moveDown', item.id)"
>
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#e8eaf0]" title="Move down" :disabled="idx === items.length - 1" @click.stop="emit('moveDown', item.id)">
<ArrowDown :size="12" />
</button>
<button
class="q-action-btn q-action-danger"
title="Remove"
@click.stop="emit('remove', item.id)"
>
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5 text-[#6b7385] hover:text-[#ef4444] hover:bg-[rgba(239,68,68,0.1)]" title="Remove" @click.stop="emit('remove', item.id)">
<Trash2 :size="12" />
</button>
</Button>
</div>
</div>
@@ -189,40 +176,12 @@ function onDragEnd(): void {
align-items: center;
gap: 7px;
}
.queue-icon {
color: #a78bfa;
}
.queue-header h2 {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #e8eaf0;
}
.queue-count {
font-size: 10px;
font-weight: 700;
color: #a78bfa;
padding: 1px 7px;
border-radius: 10px;
background: rgba(167, 139, 250, 0.1);
font-variant-numeric: tabular-nums;
}
.queue-toggle {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
cursor: pointer;
transition: all 0.2s;
}
.queue-toggle:hover {
background: rgba(255, 255, 255, 0.04);
color: #e8eaf0;
}
.queue-list {
display: flex;
@@ -263,15 +222,6 @@ function onDragEnd(): void {
gap: 6px;
margin-bottom: 3px;
}
.priority-badge {
font-size: 7px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid;
}
.queue-wait {
font-size: 8px;
color: #6b7385;
@@ -296,30 +246,6 @@ function onDragEnd(): void {
.queue-item:hover .queue-actions {
opacity: 1;
}
.q-action-btn {
width: 22px;
height: 22px;
display: grid;
place-items: center;
border: none;
border-radius: 4px;
background: transparent;
color: #6b7385;
cursor: pointer;
transition: all 0.15s;
}
.q-action-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
color: #e8eaf0;
}
.q-action-btn:disabled {
opacity: 0.25;
cursor: default;
}
.q-action-danger:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.queue-empty {
text-align: center;
@@ -0,0 +1,219 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, Circle, ChevronRight } from '@lucide/vue'
import type { OpenTask } from '../../composables/useDashboardData'
import Button from '@/components/ui/button/Button.vue'
import Badge from '@/components/ui/Badge.vue'
defineProps<{
tasks: OpenTask[]
}>()
const emit = defineEmits<{
newTask: []
'go-board': []
}>()
const expandedId = ref<string | null>(null)
function toggleExpand(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
</script>
<template>
<div class="task-card-panel">
<div class="task-header">
<h2 class="task-title">Offene Aufgaben</h2>
<Button variant="outline" size="sm" class="h-7 text-[9px] gap-1 border-[rgba(139,124,246,0.2)] bg-[rgba(139,124,246,0.12)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.2)]" @click="emit('newTask')">
<Plus :size="12" />
<span>New Task</span>
</Button>
</div>
<div class="task-list">
<div v-if="tasks.length === 0" class="task-empty">
Keine offenen Aufgaben. Erstelle eine mit + New Task.
</div>
<TransitionGroup name="task">
<div
v-for="task in tasks"
:key="task.id"
class="task-item"
:class="{ expanded: expandedId === task.id }"
@click="toggleExpand(task.id)"
>
<div class="task-main">
<Circle
:size="8"
class="task-source-dot"
:class="task.source === 'iris' ? 'dot-iris' : 'dot-bao'"
fill="currentColor"
/>
<div class="task-content">
<div class="task-title-row">
<span class="task-name">{{ task.title }}</span>
<span class="task-time">{{ task.createdAt }}</span>
</div>
<Badge
variant="outline"
:class="task.source === 'iris'
? 'bg-[rgba(167,139,250,0.15)] text-[#a78bfa] border-0 text-[8px] py-0 px-1.5'
: 'bg-[rgba(59,130,246,0.15)] text-[#3b82f6] border-0 text-[8px] py-0 px-1.5'"
>
{{ task.source === 'iris' ? 'Iris' : 'Bao' }}
</Badge>
</div>
</div>
<div v-if="expandedId === task.id" class="task-detail">
{{ task.detail }}
</div>
</div>
</TransitionGroup>
</div>
<Button variant="ghost" class="w-full mt-3 h-9 gap-1.5 text-[10px] border border-[rgba(139,124,246,0.15)] bg-[rgba(139,124,246,0.08)] text-[#a78bfa] hover:bg-[rgba(139,124,246,0.15)]" @click="emit('go-board')">
<span>Zum Task Board</span>
<ChevronRight :size="14" />
</Button>
</div>
</template>
<style scoped>
.task-card-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background: rgba(22, 27, 34, 0.65);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
transition: border-color 0.2s ease;
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.task-card-panel:hover {
border-color: rgba(139, 124, 246, 0.15);
}
.task-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-title {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #e8eaf0;
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
border: 1px solid transparent;
}
.task-item:hover {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(139, 124, 246, 0.08);
}
.task-item.expanded {
background: rgba(139, 124, 246, 0.04);
border-color: rgba(139, 124, 246, 0.1);
}
.task-main {
display: flex;
align-items: flex-start;
gap: 8px;
}
.task-source-dot {
margin-top: 4px;
flex-shrink: 0;
}
.dot-iris {
color: #a78bfa;
}
.dot-bao {
color: #3b82f6;
}
.task-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.task-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.task-name {
font-size: 10px;
font-weight: 500;
color: #d1d5db;
line-height: 1.35;
}
.task-time {
font-size: 8.5px;
color: #6b7385;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.task-detail {
padding: 6px 10px;
margin: 0 0 2px 16px;
font-size: 9.5px;
color: #7e8799;
line-height: 1.45;
background: rgba(0, 0, 0, 0.15);
border-radius: 6px;
border-left: 2px solid rgba(139, 124, 246, 0.2);
}
.task-empty {
text-align: center;
padding: 16px 8px;
font-size: 10px;
color: #6b7385;
line-height: 1.5;
}
/* TransitionGroup */
.task-enter-active {
transition: all 0.3s ease;
}
.task-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.task-enter-from {
opacity: 0;
transform: translateY(-6px);
}
.task-leave-to {
opacity: 0;
transform: translateY(6px);
}
.task-move {
transition: transform 0.3s ease;
}
</style>
+218 -281
View File
@@ -1,22 +1,11 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { ref, computed, toRef, onMounted, onUnmounted } from 'vue'
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
interface AgentData {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
hero?: boolean
task?: string
runtime?: string
}
import type { AgentNodeData } from '../../composables/useDashboardData'
import { useTeamNetworkSvg } from '../../composables/useTeamNetworkSvg'
const props = defineProps<{
agents: AgentData[]
agents: AgentNodeData[]
heroId?: string
activeAgents?: string[]
}>()
@@ -25,31 +14,27 @@ const emit = defineEmits<{
select: [id: string]
}>()
// ── Layout refs ──
// ── Network ref ──
const networkRef = ref<HTMLDivElement | null>(null)
interface CardBox {
left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
// ── Computed data ──
const hero = computed(() => props.agents.find(a => a.id === props.heroId) ?? props.agents[0])
const childAgents = computed(() => props.agents.filter(a => a.id !== props.heroId))
const heroId = computed(() => props.heroId ?? props.agents[0]?.id ?? '')
function isActive(id: string): boolean {
return props.activeAgents?.includes(id) ?? false
}
// ── SVG composable ──
const {
svgWidth,
svgHeight,
childAgents,
connectionPaths,
storePathRef,
storePulseRef,
storePulseRef2,
} = useTeamNetworkSvg(networkRef, toRef(props, 'agents'), heroId, isActive)
// ── Icon resolver ──
function resolveIcon(iconName: string) {
switch (iconName) {
@@ -63,204 +48,49 @@ function resolveIcon(iconName: string) {
}
}
// ── Position measurement ──
function updatePositions() {
if (!networkRef.value) return
const rect = networkRef.value.getBoundingClientRect()
svgWidth.value = rect.width
svgHeight.value = rect.height
const cards = networkRef.value.querySelectorAll('[data-agent-id]')
const positions: Record<string, CardBox> = {}
cards.forEach(el => {
const id = el.getAttribute('data-agent-id')
if (!id) return
const r = el.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
// ── Runtime formatter ──
function formatRuntime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
// ── SVG path computation ──
interface ConnectionPath {
d: string
length: number
// ── Model formatter ──
function formatModel(model: string): string {
const parts = model.split('/')
const name = parts[parts.length - 1]
return name.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const result: Record<string, ConnectionPath | null> = {}
const pos = cardPositions.value
const heroEntry = props.agents.find(a => a.id === props.heroId)
const heroId = heroEntry?.id ?? ''
const iris = heroId ? pos[heroId] : undefined
if (!iris) return result
// ── Mobile media query ──
const isMobile = ref(false)
let mq: MediaQueryList | null = null
const children = childAgents.value
const total = children.length
if (total === 0) return result
for (let idx = 0; idx < total; idx++) {
const agent = children[idx]
const agentPos = pos[agent.id]
if (!agentPos) {
result[agent.id] = null
continue
function onMqChange(e: MediaQueryListEvent) {
isMobile.value = e.matches
}
// Spread start points across Iris bottom edge (30%-70% range)
const t = total > 1 ? idx / (total - 1) : 0.5
const startX = iris.left + iris.width * (0.38 + t * 0.24)
const startY = iris.bottom - 1
// Determine column: left or right of Iris center
const isLeftColumn = agentPos.cx < iris.cx
// End point: approach from side, 8px before card edge
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
const endY = agentPos.cy
// Bézier control points
const cp1x = startX
const cp1y = startY + 70
const cp2x = endX + (isLeftColumn ? 35 : -35)
const cp2y = endY - 10
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Pulse animation (JS-driven via requestAnimationFrame) ──
let animFrameId: number | null = null
let lastAnimTime = 0
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
function refreshPathLengths() {
for (const id of childAgents.value.map(a => a.id)) {
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `10 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
}
}
function startPulseAnimation() {
const speeds: Record<string, number> = {}
refreshPathLengths()
for (const id of childAgents.value.map(a => a.id)) {
const p = connectionPaths.value[id]
if (p && p.length > 0) {
speeds[id] = p.length / 3000
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
const children = childAgents.value
for (let i = 0; i < children.length; i++) {
const id = children[i].id
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (!pathEl || !pulseEl || !p) continue
const len = p.length
if (len <= 0) continue
const currentOffset = pulseOffsets.value[id] ?? 0
const newOffset = currentOffset + (speeds[id] ?? len / 3000) * dt
const cycleLen = len + 10
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
// Wait for SVG to render so path refs are populated
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
// Paths changed — recalculate lengths and dasharrays
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
onMounted(() => {
mq = window.matchMedia('(max-width: 600px)')
isMobile.value = mq.matches
mq.addEventListener('change', onMqChange)
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
if (mq) {
mq.removeEventListener('change', onMqChange)
}
})
function visibleTags(tags: string[]) {
if (!isMobile.value || tags.length <= 4) {
return { shown: tags, overflow: 0 }
}
return { shown: tags.slice(0, 4), overflow: tags.length - 4 }
}
// ── Hero computed ──
const hero = computed(() => props.agents.find(a => a.id === heroId.value) ?? props.agents[0])
</script>
<template>
@@ -316,7 +146,7 @@ onUnmounted(() => {
opacity="0.5"
/>
<!-- Pulse line (white dashed segment moving along) -->
<!-- Pulse line 1 (white dashed segment moving along) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef(agent.id)"
@@ -328,6 +158,19 @@ onUnmounted(() => {
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 1 : 0.4"
/>
<!-- Pulse line 2 (offset by half cycle) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef2(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 0.8 : 0.3"
/>
</template>
</svg>
@@ -356,19 +199,27 @@ onUnmounted(() => {
<span class="card-role-tag" :style="{ background: `${hero.color}18`, color: hero.color, borderColor: `${hero.color}30` }">{{ hero.role }}</span>
</div>
<p class="card-desc">{{ hero.description }}</p>
<span v-if="hero.task" class="node-task">
<span class="node-task-dot"></span>
{{ hero.task }}
<div v-if="hero.currentTask" class="task-row">
<span class="node-task">
<span class="node-task-dot" :style="{ color: hero.color }"></span>
{{ hero.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(hero.runtimeSeconds) }}</span>
<span v-if="hero.model" class="node-model">{{ formatModel(hero.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</span>
</div>
<div class="card-tags">
<span v-for="tag in hero.tags" :key="tag" class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
<template v-for="(tag, idx) in visibleTags(hero.tags).shown" :key="tag">
<span class="card-tag" :style="{ background: `${hero.color}18`, color: hero.color }">{{ tag }}</span>
</template>
<span v-if="visibleTags(hero.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${hero.color}18`, color: hero.color }">+{{ visibleTags(hero.tags).overflow }}</span>
</div>
</div>
</div>
<div class="card-footer-action">
<span>ROLE CARD</span>
<span class="arrow">&rarr;</span>
<span v-if="hero.runtime" class="node-runtime">{{ hero.runtime }}</span>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
@@ -402,19 +253,27 @@ onUnmounted(() => {
<span class="card-role-tag" :style="{ background: `${agent.color}18`, color: agent.color, borderColor: `${agent.color}30` }">{{ agent.role }}</span>
</div>
<p class="card-desc">{{ agent.description }}</p>
<span v-if="agent.task" class="node-task">
<span class="node-task-dot"></span>
{{ agent.task }}
<div v-if="agent.currentTask" class="task-row">
<span class="node-task">
<span class="node-task-dot" :style="{ color: agent.color }"></span>
{{ agent.currentTask }}
</span>
<span class="node-runtime">{{ formatRuntime(agent.runtimeSeconds) }}</span>
<span v-if="agent.model" class="node-model">{{ formatModel(agent.model) }}</span>
</div>
<div v-else class="idle-row">
<span class="idle-badge">Idle</span>
</div>
<div class="card-tags">
<span v-for="tag in agent.tags" :key="tag" class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
<template v-for="(tag, idx) in visibleTags(agent.tags).shown" :key="tag">
<span class="card-tag" :style="{ background: `${agent.color}18`, color: agent.color }">{{ tag }}</span>
</template>
<span v-if="visibleTags(agent.tags).overflow > 0" class="card-tag tag-overflow" :style="{ background: `${agent.color}18`, color: agent.color }">+{{ visibleTags(agent.tags).overflow }}</span>
</div>
</div>
</div>
<div class="card-footer-action">
<span>ROLE CARD</span>
<span class="arrow">&rarr;</span>
<span v-if="agent.runtime" class="node-runtime">{{ agent.runtime }}</span>
<div class="card-arrow">
<span class="arrow-icon">&rarr;</span>
</div>
</article>
</div>
@@ -467,26 +326,33 @@ onUnmounted(() => {
transition: border-color 0.3s, box-shadow 0.3s;
}
/* ── Agent Card (inlined from old AgentCard.vue) ── */
/* ── Agent Card ── */
.agent-card {
background: var(--panel, #11141b);
border: 1px solid var(--line, #1f2330);
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
padding: 18px;
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
overflow: hidden;
position: relative;
}
.agent-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: var(--card-color, #8b7cf6);
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
}
.hero-card {
border-color: rgba(139, 124, 246, 0.2);
background: rgba(18, 22, 30, 0.45);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.06);
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
}
.hero-card:hover {
background: rgba(18, 22, 30, 0.65);
border-color: #8b7cf6;
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
}
@@ -535,6 +401,59 @@ onUnmounted(() => {
line-height: 1.5;
margin: 0 0 8px;
}
/* ── Task + Runtime Row ── */
.task-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.node-task {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #9ea5b3;
line-height: 1.4;
flex: 1;
min-width: 0;
}
.node-task-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
vertical-align: middle;
}
.node-runtime {
font-size: 9px;
color: #6b7385;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.node-model {
font-size: 8.5px;
color: #6b7385;
font-weight: 500;
flex-shrink: 0;
margin-left: 6px;
}
/* ── Idle Row ── */
.idle-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.idle-badge {
font-size: 9px;
color: #6b7385;
padding: 2px 8px;
border-radius: 4px;
background: rgba(107, 115, 133, 0.15);
}
/* ── Tags ── */
.card-tags {
display: flex;
flex-wrap: wrap;
@@ -548,57 +467,75 @@ onUnmounted(() => {
border-radius: 5px;
letter-spacing: 0.02em;
}
.card-footer-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
margin-top: 12px;
padding-top: 10px;
border-top: 1px solid var(--line, #1f2330);
font-size: 9px;
font-weight: 600;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.06em;
.tag-overflow {
opacity: 0.7;
}
.card-footer-action .arrow {
font-size: 13px;
/* ── Hover Arrow ── */
.card-arrow {
position: absolute;
right: 12px;
bottom: 12px;
color: #6b7385;
opacity: 0;
transform: translateX(-6px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.agent-card:hover .card-arrow {
opacity: 1;
transform: translateX(0);
}
.arrow-icon {
font-size: 14px;
line-height: 1;
}
.agent-card:hover .card-footer-action {
color: var(--card-color, #8b7cf6);
}
/* ── Node Task ── */
.node-task {
display: block;
font-size: 10px;
color: #9ea5b3;
margin-bottom: 8px;
line-height: 1.4;
}
.node-task-dot {
display: inline-block;
margin-right: 4px;
font-size: 8px;
vertical-align: middle;
}
/* ── Node Runtime ── */
.node-runtime {
font-size: 9px;
color: #6b7385;
font-variant-numeric: tabular-nums;
margin-left: auto;
/* ── Tablet ── */
@media (max-width: 900px) {
.agent-grid {
max-width: 100%;
gap: 12px;
}
.hero-slot {
max-width: 100%;
}
.card-name {
font-size: 13px;
}
.card-desc {
font-size: 9.5px;
}
}
@media (max-width: 720px) {
/* ── Mobile ── */
@media (max-width: 600px) {
.agent-grid {
grid-template-columns: 1fr;
gap: 10px;
}
.agent-card {
padding: 12px;
}
.card-icon-wrap {
width: 34px;
height: 34px;
}
.card-name {
font-size: 12px;
}
.card-role-tag {
font-size: 7.5px;
}
.card-desc {
font-size: 9px;
}
.card-tag {
font-size: 8px;
padding: 1px 6px;
}
.cards-layer {
gap: 20px;
gap: 24px;
}
}
</style>
+6 -6
View File
@@ -36,8 +36,8 @@ defineEmits<{
align-items: center;
gap: 12px;
padding: 10px 20px;
border-bottom: 1px solid var(--line, #1f2330);
background: var(--panel, #11141b);
border-bottom: 1px solid var(--nx-line, #1f2330);
background: var(--nx-panel, #11141b);
}
.mobile-menu { display: none; }
.search {
@@ -46,9 +46,9 @@ defineEmits<{
gap: 8px;
flex: 1;
padding: 6px 12px;
border: 1px solid var(--line, #1f2330);
border: 1px solid var(--nx-line, #1f2330);
border-radius: 7px;
color: var(--text-dim, #6f7889);
color: var(--nx-text-dim, #6f7889);
font-size: 11px;
}
.search kbd {
@@ -82,13 +82,13 @@ defineEmits<{
padding: 5px 10px;
border: none;
border-radius: 6px;
background: var(--accent, #7b6ef2);
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(--line, #1f2330); border-radius: 6px; background: transparent; color: var(--accent, #7b6ef2); cursor: pointer; }
.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>
@@ -154,9 +154,9 @@ async function logout() {
.nav-separator {
height: 1px;
margin: 6px 10px;
background: var(--line, #1f2330);
background: var(--nx-line, #1f2330);
}
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line, #1f2330); }
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--nx-line, #1f2330); }
.sidebar-bottom > button {
display: flex;
align-items: center;
@@ -171,8 +171,8 @@ async function logout() {
cursor: pointer;
transition: background .15s, color .15s;
}
.sidebar-bottom > button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
.sidebar-bottom > button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
.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;
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
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',
},
},
defaultVariants: {
variant: 'default',
},
},
)
interface Props {
variant?: NonNullable<VariantProps<typeof badgeVariants>['variant']>
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
})
</script>
<template>
<span :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</span>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('rounded-xl border bg-card text-card-foreground shadow', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('p-6 pt-0', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('flex flex-col space-y-1.5 p-6', props.class)">
<slot />
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('font-semibold leading-none tracking-tight', props.class)">
<slot />
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
open?: boolean
class?: string
}
const props = withDefaults(defineProps<Props>(), {
open: false,
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const visible = ref(props.open)
watch(
() => props.open,
(val) => {
visible.value = val
},
)
function close() {
emit('update:open', false)
}
function onOverlayClick() {
close()
}
defineExpose({ close })
</script>
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="onOverlayClick"
>
<!-- Overlay -->
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm" />
<!-- Content -->
<div
:class="
cn(
'relative z-50 w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
props.class,
)
"
>
<slot :close="close" />
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import type { HTMLAttributes } from 'vue'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<p :class="cn('text-sm text-muted-foreground', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<div :class="cn('flex flex-col space-y-1.5 text-center sm:text-left', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<h2 :class="cn('text-lg font-semibold leading-none tracking-tight', props.class)">
<slot />
</h2>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<input
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
v-bind="$attrs"
/>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
modelValue?: string
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<select
:value="modelValue"
:disabled="disabled"
:class="
cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<slot />
</select>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<textarea
:class="
cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
v-bind="$attrs"
/>
</template>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { CheckCircle, XCircle, Info, X } from '@lucide/vue'
import { useToast } from '../../composables/useToast'
const { toasts, remove } = useToast()
const typeConfig: Record<string, { icon: any; color: string; bg: string }> = {
success: {
icon: CheckCircle,
color: '#22c55e',
bg: 'rgba(34, 197, 94, 0.10)',
},
error: {
icon: XCircle,
color: '#ef4444',
bg: 'rgba(239, 68, 68, 0.10)',
},
info: {
icon: Info,
color: '#3b82f6',
bg: 'rgba(59, 130, 246, 0.10)',
},
}
</script>
<template>
<Teleport to="body">
<div class="toast-container" role="status" aria-live="polite">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast-item"
:style="{
'--toast-color': typeConfig[toast.type].color,
'--toast-bg': typeConfig[toast.type].bg,
}"
>
<div class="toast-icon-wrap">
<component :is="typeConfig[toast.type].icon" :size="18" />
</div>
<span class="toast-message">{{ toast.message }}</span>
<button class="toast-close" @click="remove(toast.id)" aria-label="Dismiss">
<X :size="14" />
</button>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 400px;
pointer-events: none;
}
.toast-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px 12px 12px;
border-radius: 10px;
border: 1px solid color-mix(in srgb, var(--toast-color) 25%, transparent);
background: rgba(17, 20, 27, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow:
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: #e8eaf0;
font-size: 12.5px;
line-height: 1.4;
}
.toast-icon-wrap {
flex-shrink: 0;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--toast-bg);
color: var(--toast-color);
}
.toast-message {
flex: 1;
min-width: 0;
}
.toast-close {
flex-shrink: 0;
display: grid;
place-items: center;
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
color: #6b7385;
cursor: pointer;
opacity: 0.5;
transition: all 0.15s;
}
.toast-close:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
color: #e8eaf0;
}
/* Transition animations */
.toast-enter-active {
transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1);
}
.toast-leave-active {
transition: all 0.25s ease-in;
}
.toast-enter-from {
opacity: 0;
transform: translateX(60px) scale(0.92);
}
.toast-leave-to {
opacity: 0;
transform: translateX(60px) scale(0.92);
}
.toast-move {
transition: transform 0.3s ease;
}
</style>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
import { buttonVariants } from '.'
import type { ButtonProps } from '.'
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'default',
size: 'default',
disabled: false,
type: 'button',
})
const classes = computed(() =>
cn(buttonVariants({ variant: props.variant, size: props.size }), props.class),
)
</script>
<template>
<button :type="type" :disabled="disabled" :class="classes">
<slot />
</button>
</template>
@@ -0,0 +1,42 @@
import type { HTMLAttributes } from 'vue'
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-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: {
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',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
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',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
export interface ButtonProps {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
type?: 'button' | 'submit' | 'reset'
}
+416 -244
View File
@@ -1,37 +1,47 @@
import { ref, reactive, computed } from 'vue'
import { ref, computed } from 'vue'
// ── Shared State (singleton: same state regardless of how many times useDashboardData() is called) ──
const sessionStart = Date.now()
// Intervals registry for cleanup
const intervals: ReturnType<typeof setInterval>[] = []
let cleanupRegistered = false
// ── Interfaces (exported for components) ──
export interface AgentNodeData {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
model?: string
hero?: boolean
currentTask: string
goal: string
progress: number
workload: number // 0-100
active: boolean
runtimeSeconds: number
workingFeed: string[]
workingFeed: Array<{ time: string; text: string }>
thinkingStream?: Array<{ time: string; text: string }>
}
export interface MissionData {
export interface OpenTask {
id: string
name: string
progress: number
currentTask: string
lastActivity: string
remainingTasks: number
status: 'healthy' | 'attention' | 'blocked' | 'paused'
title: string
detail: string
source: 'bao' | 'iris'
createdAt: string
}
export interface FeedEntry {
time: string
agent: string
action: string
timestamp: number
timestamp: string
}
export interface ChatMessage {
@@ -48,263 +58,343 @@ export interface QueueItem {
waitTime: string
}
const now = Date.now()
// ── API Response Interfaces ──
export function useDashboardData() {
const sessionStart = Date.now()
interface DashboardStatusResponse {
gatewayOk: boolean
irisStatus: string
activeAgents: number
pendingTasks: number
}
// Runtime counter
interface DashboardAgentInfo {
id: string
name: string
role: string
model: string
isActive: boolean
currentTask: string
description?: string
tags?: string[]
}
interface DashboardOperationEntry {
agent: string
action: string
timestamp: string
time: string
}
interface DashboardChatMessage {
role: 'user' | 'assistant'
content: string
timestamp: string
}
interface DashboardSendResponse {
ok: boolean
reply?: string
error?: string
}
interface DashboardQueueItem {
id: string
name: string
status: string
}
// ── Agent Catalog (static enrichment) ──
const AGENT_CATALOG: Record<string, Partial<AgentNodeData>> = {
iris: {
description: 'Zentrale operative Führungsinstanz. Strukturiert Aufgaben, bewertet Risiken, steuert spezialisierte Agenten und eskaliert kritische Entscheidungen.',
tags: ['Orchestration', 'Delegation', 'Approval', 'Risk Management'],
color: '#8b7cf6',
icon: 'bot',
hero: true,
goal: 'Mission Control — maximale Autonomie bei kontrolliertem Risiko',
progress: 90,
workload: 60,
workingFeed: [],
thinkingStream: [],
},
developer: {
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
color: '#3b82f6',
icon: 'code',
goal: 'Nexus Dashboard & Dungeon System',
progress: 70,
workload: 65,
workingFeed: [],
thinkingStream: [],
},
architekt: {
description: 'Verwaltet die gesamte Server-Infrastruktur. Deployt Services, konfiguriert Docker, Nginx und Firewall. Stellt sicher, dass die Produktivumgebung stabil und sicher läuft.',
tags: ['Docker', 'Nginx', 'CI/CD', 'Firewall', 'VPS'],
color: '#eab308',
icon: 'server',
goal: 'Stabile Zero-Downtime-Deployments',
progress: 60,
workload: 45,
workingFeed: [],
thinkingStream: [],
},
researcher: {
description: 'Spezialisierter Recherche-Agent. Sucht online, prüft Quellen, analysiert Inhalte (inkl. YouTube-Videos) und übergibt strukturierte Erkenntnisse. Ausschließlich Lese- und Analyse-Rechte.',
tags: ['Research', 'Quellenprüfung', 'Analyse', 'Docs'],
color: '#22c55e',
icon: 'search',
goal: 'Verifizierte, strukturierte Recherche-Ergebnisse',
progress: 40,
workload: 30,
workingFeed: [],
thinkingStream: [],
},
reviewer: {
description: 'Code-Qualitätskontrolle. Prüft Diffs auf Bugs, Regressionen, Sicherheitslücken und Wartbarkeit. Berichtet Findings strukturiert und knapp.',
tags: ['Code Review', 'Testing', 'Security', 'Quality'],
color: '#a855f7',
icon: 'shield',
goal: 'Zero critical findings before merge',
progress: 85,
workload: 55,
workingFeed: [],
thinkingStream: [],
},
executor: {
description: 'Einziger Agent mit Host-Exec-Rechten. Führt Docker- und Shell-Befehle auf dem VPS aus — ausschließlich im Auftrag von Iris. Handelt niemals eigeninitiativ.',
tags: ['Docker', 'Shell', 'Host', 'Deployment'],
color: '#f59e0b',
icon: 'server',
goal: 'Sichere Host-Execution im Allowlist-Rahmen',
progress: 95,
workload: 20,
workingFeed: [],
thinkingStream: [],
},
// Alias: API sends "programmer" but AGENT_CATALOG uses "developer" as canonical key
programmer: {
description: 'Primärer Entwicklungsagent. Implementiert Features, behebt Bugs und schreibt Code im gesamten Stack — autonom im Rahmen seines Scopes.',
tags: ['Full-Stack', 'TypeScript', 'C#', 'Vue', '.NET', 'Builds'],
color: '#3b82f6',
icon: 'code',
goal: 'Nexus Dashboard & Dungeon System',
progress: 70,
workload: 65,
workingFeed: [],
thinkingStream: [],
},
}
function enrichAgent(api: DashboardAgentInfo): AgentNodeData {
const catalog = AGENT_CATALOG[api.id] ?? AGENT_CATALOG['reviewer']
return {
id: api.id,
name: api.name,
role: api.role,
model: api.model,
currentTask: api.currentTask ?? 'Idle',
active: api.isActive,
description: api.description ?? catalog.description ?? '',
tags: api.tags ?? catalog.tags ?? [],
color: catalog.color ?? '#6b7385',
icon: catalog.icon ?? 'bot',
hero: catalog.hero ?? false,
goal: catalog.goal ?? 'No goal set',
progress: catalog.progress ?? 0,
workload: catalog.workload ?? 0,
runtimeSeconds: 0,
workingFeed: catalog.workingFeed ?? [],
thinkingStream: catalog.thinkingStream ?? [],
}
}
// ── Helper: API Fetch with auth ──
async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
const base = '' // same-origin proxy
return fetch(`${base}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init.headers as Record<string, string> ?? {}),
},
})
}
// ── State ──
// Status
const gatewayOk = ref(true)
const irisStatus = ref('Active')
const activeAgents = ref(0)
const pendingTasks = ref(0)
// Agents
const agents = ref<AgentNodeData[]>([])
// Chat
const chatMessages = ref<ChatMessage[]>([])
const irisBusy = ref(false)
const irisFocus = ref('')
const busySince = ref(0)
// Operations Feed
const feedEntries = ref<FeedEntry[]>([])
// Open Tasks (mock only no API endpoint)
const openTasks = ref<OpenTask[]>([
{ id: 't1', title: 'Agent Thinking Panel visualisieren', detail: 'Live-Animation der Denkprozesse im AgentModal', source: 'iris', createdAt: '22:30' },
{ id: 't2', title: 'CI/CD Pipeline Monitoring Dashboard', detail: 'Echtzeit-Status der Gitea Actions im Dashboard', source: 'iris', createdAt: '21:15' },
{ id: 't3', title: 'Dungeon System Dokumentation', detail: 'API-Doku für Room-Generation-Endpunkte schreiben', source: 'bao', createdAt: '20:00' },
])
// Queue
const queue = ref<QueueItem[]>([])
// Runtime
const runtimeSeconds = ref(0)
let runtimeInterval: ReturnType<typeof setInterval> | null = null
function startRuntime() {
const startTs = sessionStart
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
runtimeInterval = setInterval(() => {
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
}, 1000)
}
// ── Fetch Functions ──
function stopRuntime() {
if (runtimeInterval) {
clearInterval(runtimeInterval)
runtimeInterval = null
async function fetchStatus(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/status')
if (!res.ok) return
const data: DashboardStatusResponse = await res.json()
gatewayOk.value = data.gatewayOk
irisStatus.value = data.irisStatus
activeAgents.value = data.activeAgents
pendingTasks.value = data.pendingTasks
} catch {
// API unreachable keep current values
}
}
const formatRuntime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
async function fetchAgents(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/agents')
if (!res.ok) return
const data: DashboardAgentInfo[] = await res.json()
agents.value = data.map(enrichAgent)
} catch {
// API unreachable keep current values
}
}
const irisRuntime = computed(() => formatRuntime(runtimeSeconds.value))
async function fetchOperations(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/operations?limit=20')
if (!res.ok) return
const data: DashboardOperationEntry[] = await res.json()
feedEntries.value = data.map((entry) => ({
time: entry.time,
agent: entry.agent,
action: entry.action,
timestamp: entry.timestamp,
}))
} catch {
// API unreachable keep current values
}
}
// Agent runtimes (simulated)
const agentStartTimes = reactive<Record<string, number>>({
iris: now - 28800000,
developer: now - 3600000,
devops: now - 1800000,
researcher: now - 2700000,
reviewer: now - 900000,
async function fetchChatMessages(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/chat/messages?limit=50')
if (!res.ok) return
const data: DashboardChatMessage[] = await res.json()
// Merge instead of replace — only add messages not already present
const existingTexts = new Set(chatMessages.value.map(m => m.text))
const existingTimestamps = new Set(chatMessages.value.map(m => m.timestamp))
for (const msg of data) {
const msgTime = new Date(msg.timestamp).getTime()
if (existingTexts.has(msg.content) && existingTimestamps.has(msgTime)) continue
chatMessages.value.push({
id: `msg-${msgTime}-${msg.role}`,
sender: msg.role === 'assistant' ? 'iris' : 'user',
text: msg.content,
timestamp: msgTime,
})
const getAgentRuntime = (id: string): string => {
const start = agentStartTimes[id]
if (!start) return '00:00'
const secs = Math.floor((now - start) / 1000)
const m = Math.floor(secs / 60)
const s = secs % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
} catch {
// API unreachable keep current values
}
}
// Agents
const agents = ref<AgentNodeData[]>([
{
id: 'iris',
name: 'Iris',
role: 'Chief of Staff',
description: 'Koordiniert, delegiert, hält das Team tight. Die erste Anlaufstelle zwischen Boss und Maschine.',
color: '#8b7cf6',
icon: 'bot',
currentTask: 'Orchestrating Nexus Dashboard redesign',
goal: 'Complete Mission Control v3',
progress: 85,
workload: 55,
active: true,
runtimeSeconds: 28800,
workingFeed: [
'Analyzed user feedback on Dashboard',
'Delegated card redesign to Developer',
'Verifying full-width layout deployment',
'Reviewing AgentModal integration',
],
thinkingStream: [
{ time: '22:24', text: 'Analysing constraint: full-width layout' },
{ time: '22:25', text: 'Removing max-width from global CSS' },
{ time: '22:26', text: 'Verifying Dashboard grid reflow' },
],
},
{
id: 'developer',
name: 'Developer',
role: 'Backend & Frontend',
description: 'Implements features across the stack with TypeScript, C#, and Vue.',
color: '#3b82f6',
icon: 'code',
currentTask: 'Building Dungeon System API endpoints',
goal: 'Complete Dungeon CRUD + room generation',
progress: 62,
workload: 65,
active: true,
runtimeSeconds: 3600,
workingFeed: [
'Created DungeonController',
'Defined dungeon schema',
'Implementing room generation algorithm',
'Writing unit tests for RoomFactory',
],
thinkingStream: [
{ time: '22:22', text: 'Parsing dungeon spec from Iris' },
{ time: '22:23', text: 'Designing RoomFactory interface' },
{ time: '22:24', text: 'Implementing corridor connection logic' },
],
},
{
id: 'devops',
name: 'DevOps',
role: 'Infrastructure & CI/CD',
description: 'Manages Docker, deployment pipelines, and system reliability.',
color: '#eab308',
icon: 'server',
currentTask: 'Optimizing Docker Compose caching',
goal: 'Reduce build times by 40%',
progress: 45,
workload: 40,
active: false,
runtimeSeconds: 1800,
workingFeed: [
'Analyzed Docker layer cache',
'Optimized COPY order in Dockerfile',
'Added .dockerignore for node_modules',
'Testing incremental builds',
],
thinkingStream: [
{ time: '22:20', text: 'Checking build cache hit rates' },
{ time: '22:21', text: 'Benchmarking multi-stage vs single-stage' },
{ time: '22:22', text: 'Calculating potential speedup from caching' },
],
},
{
id: 'researcher',
name: 'Researcher',
role: 'Analysis & Documentation',
description: 'Researches APIs, patterns, and best practices. Maintains docs.',
color: '#22c55e',
icon: 'search',
currentTask: 'Analyzing WebSocket alternatives',
goal: 'Recommend real-time communication strategy',
progress: 30,
workload: 25,
active: true,
runtimeSeconds: 2700,
workingFeed: [
'Evaluated WebSocket vs SSE vs WebRTC',
'Documented SignalR limitations',
'Prototyping WebSocket fallback',
],
thinkingStream: [
{ time: '22:18', text: 'Cross-referencing WebSocket latency benchmarks' },
{ time: '22:19', text: 'Checking SSE browser support matrix' },
{ time: '22:20', text: 'Drafting recommendation summary' },
],
},
{
id: 'reviewer',
name: 'Reviewer',
role: 'Code Quality & Testing',
description: 'Reviews pull requests, enforces standards, runs test suites.',
color: '#a855f7',
icon: 'shield',
currentTask: 'Reviewing Dungeon System PR',
goal: 'Zero critical findings before merge',
progress: 80,
workload: 50,
active: false,
runtimeSeconds: 900,
workingFeed: [
'Reviewed DungeonController.cs',
'Found 3 minor style issues',
'Approved RoomValidator',
'Running integration tests',
],
thinkingStream: [
{ time: '22:15', text: 'Analyzing DungeonController PR diff' },
{ time: '22:16', text: 'Checking RoomValidator edge cases' },
{ time: '22:17', text: 'Verifying integration test coverage' },
],
},
])
async function fetchQueue(): Promise<void> {
try {
const res = await apiFetch('/api/dashboard/queue')
if (!res.ok) return
const data: DashboardQueueItem[] = await res.json()
queue.value = data.map((item) => ({
id: item.id,
text: item.name,
priority: (item.status === 'high' || item.status === 'medium' || item.status === 'low')
? item.status as 'high' | 'medium' | 'low'
: 'medium',
waitTime: '--',
}))
} catch {
// API unreachable keep current values
}
}
// Missions
const missions = ref<MissionData[]>([
{
id: 'dungeon-system',
name: 'Dungeon System',
progress: 62,
currentTask: 'Implement room generation',
lastActivity: '3 min ago',
remainingTasks: 8,
status: 'healthy',
},
{
id: 'dashboard-redesign',
name: 'Dashboard Redesign',
progress: 45,
currentTask: 'AI Team Network layout',
lastActivity: 'Just now',
remainingTasks: 6,
status: 'healthy',
},
{
id: 'infra-optimization',
name: 'Infra Optimization',
progress: 30,
currentTask: 'Optimize build caching',
lastActivity: '12 min ago',
remainingTasks: 4,
status: 'attention',
},
{
id: 'auth-system',
name: 'Auth System',
progress: 88,
currentTask: 'Finalize refresh token flow',
lastActivity: '45 min ago',
remainingTasks: 2,
status: 'healthy',
},
])
// ── Chat Send ──
// Feed
const feedEntries = ref<FeedEntry[]>([
{ time: '20:42', agent: 'Developer', action: 'Created DungeonController endpoints', timestamp: now - 60000 },
{ time: '20:38', agent: 'DevOps', action: 'Optimized Docker COPY order', timestamp: now - 300000 },
{ time: '20:35', agent: 'Iris', action: 'Delegated room generation to Developer', timestamp: now - 540000 },
{ time: '20:28', agent: 'Researcher', action: 'Documented WebSocket vs SSE analysis', timestamp: now - 780000 },
{ time: '20:22', agent: 'Reviewer', action: 'Approved RoomValidator PR', timestamp: now - 900000 },
{ time: '20:15', agent: 'DevOps', action: 'Added .dockerignore for node_modules', timestamp: now - 1200000 },
{ time: '20:08', agent: 'Iris', action: 'Broke down Dungeon System tasks', timestamp: now - 1500000 },
{ time: '19:55', agent: 'Developer', action: 'Defined dungeon schema models', timestamp: now - 1800000 },
])
// Chat
const chatMessages = ref<ChatMessage[]>([
{ id: 'm1', sender: 'iris', text: 'Guten Abend, Bao. Ready to continue the Dungeon System?', timestamp: now - 600000 },
{ id: 'm2', sender: 'user', text: "Yes, what's the status?", timestamp: now - 540000 },
{ id: 'm3', sender: 'iris', text: "Developer is at 62% on room generation. Reviewer approved the schema. I'd recommend focusing on the room connection logic next.", timestamp: now - 480000 },
])
const irisBusy = ref(true)
const irisFocus = ref('Breaking down Dungeon System for DevOps and Developer')
// Queue
const queue = ref<QueueItem[]>([
{ id: 'q1', text: 'Deploy latest dashboard build to preview', priority: 'high', waitTime: '2 min' },
{ id: 'q2', text: 'Review infrastructure cost analysis', priority: 'medium', waitTime: '8 min' },
{ id: 'q3', text: 'Schedule B2 German lesson review', priority: 'low', waitTime: '15 min' },
{ id: 'q4', text: 'Update project roadmap document', priority: 'medium', waitTime: '12 min' },
])
function sendChat(text: string): void {
async function sendChatMessage(text: string): Promise<void> {
if (!text.trim()) return
// Optimistic add
chatMessages.value.push({
id: `user-${Date.now()}`,
sender: 'user',
text: text.trim(),
timestamp: Date.now(),
})
irisBusy.value = true
busySince.value = Date.now()
try {
const res = await apiFetch('/api/dashboard/chat/send', {
method: 'POST',
body: JSON.stringify({ message: text.trim() }),
})
const data: DashboardSendResponse = await res.json()
if (data.ok && data.reply) {
chatMessages.value.push({
id: `iris-${Date.now()}`,
sender: 'iris',
text: data.reply,
timestamp: Date.now(),
})
} else if (data.error) {
chatMessages.value.push({
id: `error-${Date.now()}`,
sender: 'iris',
text: `⚠️ ${data.error}`,
timestamp: Date.now(),
})
}
} catch {
chatMessages.value.push({
id: `error-${Date.now()}`,
sender: 'iris',
text: '⚠️ Connection error. Please try again.',
timestamp: Date.now(),
})
} finally {
irisBusy.value = false
busySince.value = 0
irisFocus.value = text.trim()
}
}
// ── Queue Operations ──
function removeQueueItem(id: string): void {
const idx = queue.value.findIndex(q => q.id === id)
@@ -322,23 +412,105 @@ export function useDashboardData() {
if (item) item.priority = priority
}
// ── Runtime ──
function startRuntime(): void {
const startTs = sessionStart
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
runtimeInterval = setInterval(() => {
runtimeSeconds.value = Math.floor((Date.now() - startTs) / 1000)
}, 1000)
}
function stopRuntime(): void {
if (runtimeInterval) {
clearInterval(runtimeInterval)
runtimeInterval = null
}
}
const formatRuntime = (seconds: number): string => {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
const irisRuntime = computed(() => formatRuntime(runtimeSeconds.value))
const getAgentRuntime = (_id: string): string => {
// Could be extended to track per-agent runtimes from API
return formatRuntime(runtimeSeconds.value)
}
// ── Polling starten (nur einmal) ──
function startPolling(): void {
if (cleanupRegistered) return
cleanupRegistered = true
// Initial fetches
fetchStatus()
fetchAgents()
fetchOperations()
fetchChatMessages()
fetchQueue()
// Polling intervals
intervals.push(setInterval(fetchStatus, 5000))
intervals.push(setInterval(fetchAgents, 10000))
intervals.push(setInterval(fetchOperations, 10000))
intervals.push(setInterval(fetchChatMessages, 3000))
intervals.push(setInterval(fetchQueue, 10000))
}
function stopPolling(): void {
for (const interval of intervals) {
clearInterval(interval)
}
intervals.length = 0
cleanupRegistered = false
}
// ── Composable Export ──
export function useDashboardData() {
// Start polling on first call
startPolling()
return {
// State
agents,
missions,
openTasks,
feedEntries,
chatMessages,
irisBusy,
irisFocus,
busySince,
irisRuntime,
queue,
gatewayOk,
irisStatus,
pendingTasks,
activeAgents,
// Runtime
runtimeSeconds,
getAgentRuntime,
startRuntime,
stopRuntime,
formatRuntime,
sendChat,
// Actions
sendChatMessage,
removeQueueItem,
moveQueueItem,
changeQueuePriority,
// Fetch (for manual refresh)
fetchStatus,
fetchAgents,
fetchOperations,
fetchChatMessages,
fetchQueue,
}
}
@@ -0,0 +1,266 @@
import { ref, computed, onMounted, onUnmounted, nextTick, type Ref } from 'vue'
import type { AgentNodeData } from './useDashboardData'
export interface CardBox {
left: number
right: number
top: number
bottom: number
cx: number
cy: number
width: number
height: number
}
export interface ConnectionPath {
d: string
length: number
}
export function useTeamNetworkSvg(
networkRef: Ref<HTMLElement | null>,
agents: Ref<AgentNodeData[]>,
heroId: Ref<string>,
isActive: (id: string) => boolean,
) {
// ── Layout ──
const cardPositions = ref<Record<string, CardBox>>({})
const svgWidth = ref(0)
const svgHeight = ref(0)
const childAgents = computed(() => agents.value.filter(a => a.id !== heroId.value))
function updatePositions() {
const el = networkRef.value
if (!el) return
const rect = el.getBoundingClientRect()
svgWidth.value = rect.width
svgHeight.value = rect.height
const cards = el.querySelectorAll('[data-agent-id]')
const positions: Record<string, CardBox> = {}
cards.forEach(card => {
const id = card.getAttribute('data-agent-id')
if (!id) return
const r = card.getBoundingClientRect()
positions[id] = {
left: r.left - rect.left,
right: r.left + r.width - rect.left,
top: r.top - rect.top,
bottom: r.top + r.height - rect.top,
cx: r.left + r.width / 2 - rect.left,
cy: r.top + r.height / 2 - rect.top,
width: r.width,
height: r.height,
}
})
cardPositions.value = positions
}
// ── Connection paths ──
const connectionPaths = computed<Record<string, ConnectionPath | null>>(() => {
const result: Record<string, ConnectionPath | null> = {}
const pos = cardPositions.value
const iris = pos[heroId.value]
if (!iris) return result
const children = childAgents.value
const total = children.length
if (total === 0) return result
for (let idx = 0; idx < total; idx++) {
const agent = children[idx]
const agentPos = pos[agent.id]
if (!agentPos) {
result[agent.id] = null
continue
}
// Spread start points across Iris bottom edge (30%-70% range)
const t = total > 1 ? idx / (total - 1) : 0.5
const startX = iris.left + iris.width * (0.38 + t * 0.24)
const startY = iris.bottom - 1
// Determine column: left or right of Iris center
const isLeftColumn = agentPos.cx < iris.cx
// End point: approach from side, 8px before card edge
const endX = isLeftColumn ? agentPos.right - 8 : agentPos.left + 8
const endY = agentPos.cy
// Bézier control points
const cp1x = startX
const cp1y = startY + 70
const cp2x = endX + (isLeftColumn ? 35 : -35)
const cp2y = endY - 10
const d = `M ${startX} ${startY} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${endX} ${endY}`
result[agent.id] = { d, length: 0 }
}
return result
})
// ── Path refs (template ref functions) ──
const pathElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements = ref<Record<string, SVGPathElement | null>>({})
const pulseElements2 = ref<Record<string, SVGPathElement | null>>({})
const pulseOffsets = ref<Record<string, number>>({})
const pulseOffsets2 = ref<Record<string, number>>({})
function storePathRef(id: string) {
return (el: SVGPathElement | null) => {
pathElements.value[id] = el
}
}
function storePulseRef(id: string) {
return (el: SVGPathElement | null) => {
pulseElements.value[id] = el
}
}
function storePulseRef2(id: string) {
return (el: SVGPathElement | null) => {
pulseElements2.value[id] = el
}
}
// ── Pulse animation ──
let animFrameId: number | null = null
let lastAnimTime = 0
const speeds: Record<string, number> = {}
function refreshPathLengths() {
for (const id of childAgents.value.map(a => a.id)) {
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const p = connectionPaths.value[id]
if (pathEl && p) {
p.length = pathEl.getTotalLength()
}
if (pulseEl && p && p.length > 0) {
if (pulseOffsets.value[id] === undefined) {
pulseOffsets.value[id] = 0
}
pulseEl.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
}
const pulseEl2 = pulseElements2.value[id]
if (pulseEl2 && p && p.length > 0) {
if (pulseOffsets2.value[id] === undefined) {
pulseOffsets2.value[id] = 0
}
pulseEl2.setAttribute('stroke-dasharray', `40 ${p.length}`)
pulseEl2.setAttribute('stroke-dashoffset', String(-pulseOffsets2.value[id]))
}
}
}
function startPulseAnimation() {
refreshPathLengths()
for (const id of childAgents.value.map(a => a.id)) {
const p = connectionPaths.value[id]
if (p && p.length > 0) {
speeds[id] = p.length / 3000
if (pulseOffsets.value[id] === undefined) pulseOffsets.value[id] = 0
if (pulseOffsets2.value[id] === undefined) pulseOffsets2.value[id] = 0
}
}
lastAnimTime = performance.now()
function tick(now: number) {
const dt = now - lastAnimTime
lastAnimTime = now
const children = childAgents.value
for (let i = 0; i < children.length; i++) {
const id = children[i].id
const pathEl = pathElements.value[id]
const pulseEl = pulseElements.value[id]
const pulseEl2 = pulseElements2.value[id]
const p = connectionPaths.value[id]
if (!pathEl || !pulseEl || !p) continue
const len = p.length
if (len <= 0) continue
const speed = speeds[id] ?? len / 3000
const cycleLen = len + 40
// Pulse 1
const currentOffset = pulseOffsets.value[id] ?? 0
const newOffset = currentOffset + speed * dt
pulseOffsets.value[id] = newOffset > cycleLen ? newOffset % cycleLen : newOffset
pulseEl.setAttribute('stroke-dashoffset', String(-pulseOffsets.value[id]))
// Pulse 2 (offset by half cycle)
if (pulseEl2) {
const offset2 = (pulseOffsets.value[id] + cycleLen / 2) % cycleLen
pulseOffsets2.value[id] = offset2
pulseEl2.setAttribute('stroke-dashoffset', String(-offset2))
}
}
animFrameId = requestAnimationFrame(tick)
}
animFrameId = requestAnimationFrame(tick)
}
function stopPulseAnimation() {
if (animFrameId !== null) {
cancelAnimationFrame(animFrameId)
animFrameId = null
}
}
// ── Lifecycle ──
let resizeObserver: ResizeObserver | null = null
onMounted(async () => {
await nextTick()
updatePositions()
// Wait for SVG to render so path refs are populated
await nextTick()
updatePositions()
refreshPathLengths()
startPulseAnimation()
resizeObserver = new ResizeObserver(() => {
updatePositions()
requestAnimationFrame(() => {
refreshPathLengths()
})
})
if (networkRef.value) {
resizeObserver.observe(networkRef.value)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
return {
cardPositions,
svgWidth,
svgHeight,
childAgents,
connectionPaths,
pathElements,
pulseElements,
pulseElements2,
pulseOffsets,
pulseOffsets2,
storePathRef,
storePulseRef,
storePulseRef2,
updatePositions,
refreshPathLengths,
}
}
-37
View File
@@ -1,37 +0,0 @@
import { ref, onMounted, onUnmounted } from 'vue'
export function useTimer() {
const elapsed = ref(0)
let interval: ReturnType<typeof setInterval> | null = null
function start() {
if (interval !== null) return
const startTime = Date.now()
elapsed.value = 0
interval = setInterval(() => {
elapsed.value = Math.floor((Date.now() - startTime) / 1000)
}, 1000)
}
function stop() {
if (interval !== null) {
clearInterval(interval)
interval = null
}
}
function format(minutes: number, seconds: number): string {
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
const formatted = (): string => {
const m = Math.floor(elapsed.value / 60)
const s = elapsed.value % 60
return format(m, s)
}
onMounted(start)
onUnmounted(stop)
return { elapsed, formatted, start, stop }
}
+28
View File
@@ -0,0 +1,28 @@
import { ref, readonly } from 'vue'
import type { Toast } from '../types/toast'
let nextId = 1
const toasts = ref<Toast[]>([])
export function useToast() {
function add(message: string, type: Toast['type'] = 'info', durationMs = 3500) {
const id = nextId++
toasts.value.push({ id, message, type, durationMs })
if (durationMs > 0) {
setTimeout(() => remove(id), durationMs)
}
}
function remove(id: number) {
const idx = toasts.value.findIndex(t => t.id === id)
if (idx !== -1) toasts.value.splice(idx, 1)
}
return {
toasts: readonly(toasts),
success: (msg: string, durationMs?: number) => add(msg, 'success', durationMs),
error: (msg: string, durationMs?: number) => add(msg, 'error', durationMs),
info: (msg: string, durationMs?: number) => add(msg, 'info', durationMs),
remove,
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { useAuthStore } from './stores/auth'
import './style.css'
import './assets/main.css'
const pinia = createPinia()
+38 -38
View File
@@ -5,13 +5,13 @@
color: #e8eaf0;
background: #080a0f;
font-synthesis: none;
--panel: #10131a;
--panel-soft: #0d1016;
--line: #202530;
--muted: #7e8799;
--accent: #8b7cf6;
--accent-soft: rgba(139, 124, 246, .12);
--green: #51d49a;
--nx-panel: #10131a;
--nx-panel-soft: #0d1016;
--nx-line: #202530;
--nx-muted: #7e8799;
--nx-accent: #8b7cf6;
--nx-accent-soft: rgba(139, 124, 246, .12);
--nx-green: #51d49a;
}
* { box-sizing: border-box; }
@@ -22,11 +22,11 @@ button { color: inherit; font: inherit; }
.brand { display: flex; align-items: center; gap: 11px; padding: 0 8px 25px; }
.brand-mark { width: 35px; height: 35px; display: grid; place-items: center; border: 1px solid #443d7c; border-radius: 10px; background: linear-gradient(145deg, #241f44, #12121f); color: #b8adff; box-shadow: 0 0 24px rgba(139,124,246,.13); }
.brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
.brand span, .owner span { display: block; color: var(--muted); font-size: 10px; margin-top: 2px; }
.brand span, .owner span { display: block; color: var(--nx-muted); font-size: 10px; margin-top: 2px; }
.nav { display: flex; flex-direction: column; gap: 3px; }
.nav button, .sidebar-bottom > button { width: 100%; display: flex; align-items: center; gap: 10px; border: 0; padding: 9px 10px; border-radius: 7px; background: transparent; color: #8991a1; font-size: 12px; text-align: left; cursor: pointer; }
.nav button:hover, .nav button.active { color: #ececf5; background: var(--accent-soft); }
.nav button.active { box-shadow: inset 2px 0 var(--accent); }
.nav button:hover, .nav button.active { color: #ececf5; background: var(--nx-accent-soft); }
.nav button.active { box-shadow: inset 2px 0 var(--nx-accent); }
.nav button i { margin-left: auto; padding: 1px 6px; border: 1px solid #343947; border-radius: 8px; font-size: 9px; font-style: normal; }
.sidebar-bottom { margin-top: auto; border-top: 1px solid #1b1f28; padding-top: 10px; }
.owner { display: grid; grid-template-columns: 31px 1fr auto; gap: 9px; align-items: center; margin-top: 10px; padding: 10px 8px; }
@@ -38,17 +38,17 @@ main { min-width: 0; }
.search kbd { margin-left: auto; padding: 2px 5px; border: 1px solid #2c313d; border-radius: 4px; color: #606979; font-size: 9px; }
.top-actions { display: flex; align-items: center; gap: 10px; }
.connection { display: flex; gap: 6px; align-items: center; font-size: 10px; color: #8c95a5; }
.connection.live { color: var(--green); }
.connection.live { color: var(--nx-green); }
.connection.preview { color: #e6b75d; }
.ask, .refresh { display: flex; align-items: center; gap: 7px; padding: 8px 11px; border: 1px solid #37315e; border-radius: 7px; background: #18152a; color: #c4bbff; font-size: 10px; cursor: pointer; }
.content { padding: 16px 16px 60px; }
.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 28px; }
.eyebrow, .kicker { color: #7065c8; font-size: 9px; font-weight: 700; letter-spacing: .18em; }
h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.page-heading p, .placeholder p { margin: 0; color: var(--muted); font-size: 11px; }
.refresh { border-color: var(--line); background: var(--panel); color: #a5adba; }
.page-heading p, .placeholder p { margin: 0; color: var(--nx-muted); font-size: 11px; }
.refresh { border-color: var(--nx-line); background: var(--nx-panel); color: #a5adba; }
.metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
.metrics article, .panel { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); border-radius: 9px; }
.metrics article, .panel { border: 1px solid var(--nx-line); background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); border-radius: 9px; }
.metrics article { padding: 16px 17px; }
.metrics span { color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; }
.metrics strong { display: block; margin: 7px 0 5px; font-size: 24px; letter-spacing: -.04em; }
@@ -61,29 +61,29 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.panel-head h2 { margin: 4px 0 0; font-size: 13px; }
.panel-head button { border: 0; background: transparent; color: #8e96a5; font-size: 9px; }
.badge { padding: 4px 8px; border-radius: 10px; font-size: 8px; }
.badge.positive { color: var(--green); background: rgba(81,212,154,.1); }
.badge.positive { color: var(--nx-green); background: rgba(81,212,154,.1); }
.badge.warning { color: #e7b660; background: rgba(231,182,96,.1); }
.runtime-row { display: flex; align-items: center; gap: 12px; padding-top: 22px; }
.runtime-icon { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 9px; color: #ad9fff; background: var(--accent-soft); }
.runtime-icon { width: 45px; height: 45px; display: grid; place-items: center; border-radius: 9px; color: #ad9fff; background: var(--nx-accent-soft); }
.runtime-main strong, .model strong, .project strong, .event strong { display: block; font-size: 11px; }
.runtime-main span, .model small, .event small { display: block; margin-top: 4px; color: var(--muted); font-size: 9px; }
.runtime-main span, .model small, .event small { display: block; margin-top: 4px; color: var(--nx-muted); font-size: 9px; }
.pulse-bars { height: 42px; display: flex; align-items: center; gap: 3px; margin-left: auto; }
.pulse-bars i { width: 3px; min-height: 5px; border-radius: 3px; background: linear-gradient(#927fff, #443b7c); }
.model { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 9px; padding: 12px 2px; border-bottom: 1px solid #1b2029; }
.model > span:last-child { color: #687181; font-size: 8px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: #657083; }
.status-dot.online { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.status-dot.online { background: var(--nx-green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.status-dot.offline { background: #e16e75; }
.project { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
.project-letter { width: 31px; height: 31px; display: grid; place-items: center; border: 1px solid #353047; border-radius: 7px; color: #a99cf5; font-size: 10px; }
.project-info > div:first-child { display: flex; justify-content: space-between; }
.project-info span { color: var(--muted); font-size: 8px; }
.project-info span { color: var(--nx-muted); font-size: 8px; }
.project b { color: #838c9c; font-size: 9px; }
.progress { height: 3px; margin-top: 8px; overflow: hidden; border-radius: 4px; background: #242936; }
.progress i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #685ac8, #a091ff); }
.event { display: grid; grid-template-columns: auto 1fr; gap: 10px; padding: 12px 0; border-bottom: 1px solid #1b2029; }
.event > span { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #657083; }
.event > span.runtime { background: var(--green); }
.event > span.runtime { background: var(--nx-green); }
.event > span.deploy { background: #8b7cf6; }
.event > span.security { background: #e5ad52; }
.placeholder { min-height: 420px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
@@ -117,25 +117,25 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.module-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.module-card { min-height: 190px; padding: 18px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); }
.module-card { min-height: 190px; padding: 18px; border: 1px solid var(--nx-line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); }
.module-card h3, .model-detail h3, .timeline h3, .chat-shell h3 { margin: 8px 0 4px; font-size: 13px; }
.module-card p, .model-detail p, .timeline p, .chat-shell p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
.module-card p, .model-detail p, .timeline p, .chat-shell p { margin: 0; color: var(--nx-muted); font-size: 10px; line-height: 1.5; }
.module-card-head, .project-card footer { display: flex; align-items: center; justify-content: space-between; }
.project-card .progress { margin: 34px 0 12px; }
.project-card footer { color: var(--muted); font-size: 9px; }
.project-card footer { color: var(--nx-muted); font-size: 9px; }
.kanban { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; align-items: start; }
.kanban-column { min-height: 380px; padding: 12px; border: 1px solid var(--line); border-radius: 9px; background: rgba(14,17,23,.8); }
.kanban-column { min-height: 380px; padding: 12px; border: 1px solid var(--nx-line); border-radius: 9px; background: rgba(14,17,23,.8); }
.kanban-column > header { display: flex; justify-content: space-between; padding: 5px 3px 14px; color: #aeb4c0; font-size: 10px; }
.kanban-column > header b { padding: 1px 6px; border-radius: 8px; background: #242936; }
.task-card { margin-bottom: 9px; padding: 14px; border: 1px solid #252a35; border-radius: 8px; background: #12151c; }
.task-card h3 { margin: 10px 0 22px; font-size: 11px; }
.task-card select { width: 100%; margin-bottom: 12px; padding: 7px 8px; border: 1px solid #292f3b; border-radius: 7px; outline: none; color: #c8ccd5; background: #0c0f14; font: inherit; font-size: 9px; cursor: pointer; }
.task-card footer { display: flex; gap: 5px; align-items: center; color: var(--muted); font-size: 8px; }
.task-card footer { display: flex; gap: 5px; align-items: center; color: var(--nx-muted); font-size: 8px; }
.priority { font-size: 8px; text-transform: uppercase; color: #9b91e6; }.priority.critical { color: #ec7b82; }.priority.high { color: #e5b05e; }
.empty-state { padding: 35px 0; text-align: center; color: #596171; font-size: 9px; }
.agent-card { display: grid; grid-template-columns: auto 1fr auto; gap: 13px; align-items: start; }
.agent-avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 9px; color: #66d5a4; background: rgba(81,212,154,.1); }
.agent-avatar.violet { color: #a99cff; background: var(--accent-soft); }
.agent-avatar.violet { color: #a99cff; background: var(--nx-accent-soft); }
.module-list { padding: 4px 18px; }
.model-detail { display: grid; grid-template-columns: 45px 1fr auto; align-items: center; gap: 14px; padding: 19px 0; border-bottom: 1px solid #1d222c; }
.route-rank { color: #6f63c9; font-size: 12px; font-weight: 700; }
@@ -143,10 +143,10 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.timeline article { display: grid; grid-template-columns: 34px 1fr; gap: 12px; padding: 18px 0; border-bottom: 1px solid #1d222c; }
.timeline-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: #70d8aa; background: rgba(81,212,154,.1); }.timeline-icon.security { color: #e5b05e; background: rgba(229,176,94,.1); }
.chat-shell { max-width: 760px; margin: auto; padding: 0; overflow: hidden; }
.chat-shell > header { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 16px; border-bottom: 1px solid var(--line); }
.chat-shell > header { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: center; padding: 16px; border-bottom: 1px solid var(--nx-line); }
.messages { min-height: 360px; padding: 22px; }
.message { max-width: 75%; margin-bottom: 12px; padding: 12px 14px; border-radius: 10px; background: #171b23; }.message.owner { margin-left: auto; background: #211d39; }.message strong { font-size: 9px; color: #9d91eb; }
.chat-shell form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border-top: 1px solid var(--line); }
.chat-shell form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 14px; border-top: 1px solid var(--nx-line); }
.chat-shell input { min-width: 0; padding: 11px 13px; border: 1px solid #292f3b; border-radius: 8px; outline: none; color: #e7e9ef; background: #0c0f14; font: inherit; font-size: 10px; }
.chat-shell form button { width: 38px; border: 1px solid #443d7c; border-radius: 8px; color: #beb4ff; background: #211d39; }
@media (max-width: 900px) { .module-grid, .kanban { grid-template-columns: 1fr; } }
@@ -159,20 +159,20 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.settings-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.settings-grid .module-card { min-height: 165px; }
.settings-grid .badge { display: inline-block; margin-top: 28px; }
.sidebar-bottom > button.active { color: #ececf5; background: var(--accent-soft); }
.sidebar-bottom > button.active { color: #ececf5; background: var(--nx-accent-soft); }
@media (max-width: 700px) { .settings-grid { grid-template-columns: 1fr; } }
.owner { width: 100%; border: 0; color: inherit; background: transparent; text-align: left; cursor: pointer; }
.owner:hover { background: var(--accent-soft); border-radius: 8px; }
.owner:hover { background: var(--nx-accent-soft); border-radius: 8px; }
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.login-card { width: min(420px, 100%); padding: 32px; border: 1px solid var(--line); border-radius: 14px; background: linear-gradient(145deg, rgba(18,21,29,.98), rgba(10,12,18,.98)); box-shadow: 0 28px 90px rgba(0,0,0,.4); }
.login-card { width: min(420px, 100%); padding: 32px; border: 1px solid var(--nx-line); border-radius: 14px; background: linear-gradient(145deg, rgba(18,21,29,.98), rgba(10,12,18,.98)); box-shadow: 0 28px 90px rgba(0,0,0,.4); }
.login-brand { display: flex; align-items: center; gap: 12px; padding-bottom: 28px; border-bottom: 1px solid #1d222c; }
.login-brand strong { display: block; font-size: 13px; letter-spacing: .14em; }
.login-brand span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; }
.login-brand span { display: block; margin-top: 3px; color: var(--nx-muted); font-size: 10px; }
.login-heading { padding: 28px 0 20px; }
.login-heading h1 { margin-top: 9px; font-size: 25px; }
.login-heading p { margin: 0; color: var(--muted); font-size: 11px; line-height: 1.6; }
.login-heading p { margin: 0; color: var(--nx-muted); font-size: 11px; line-height: 1.6; }
.login-card form { display: grid; gap: 14px; }
.login-card label span { display: block; margin-bottom: 7px; color: #aab1bf; font-size: 10px; }
.login-card input { width: 100%; padding: 12px 13px; border: 1px solid #2a303b; border-radius: 8px; outline: none; color: #eef0f5; background: #0a0d12; font: inherit; font-size: 12px; }
@@ -184,10 +184,10 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
/* Project health row */
.project-health-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 10px; }
.health-card { padding: 12px 16px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); text-align: center; }
.health-card { padding: 12px 16px; border: 1px solid var(--nx-line); border-radius: 9px; background: linear-gradient(145deg, rgba(18,21,29,.96), rgba(12,15,21,.96)); text-align: center; }
.health-label { display: block; color: #717a8a; font-size: 8px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase; }
.health-card strong { display: block; margin-top: 6px; font-size: 22px; letter-spacing: -.04em; }
.health-online { color: var(--green); }
.health-online { color: var(--nx-green); }
.health-degraded { color: #e7b660; }
.health-offline { color: #e16e75; }
.health-unknown { color: #7e8799; }
@@ -195,9 +195,9 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
/* Runtime health indicators */
.runtime-health-row { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
.runtime-health-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.runtime-health-dot.healthy { background: var(--green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.runtime-health-dot.healthy { background: var(--nx-green); box-shadow: 0 0 7px rgba(81,212,154,.4); }
.runtime-health-dot.unhealthy { background: #e7b660; box-shadow: 0 0 7px rgba(231,182,96,.3); }
.runtime-health-text { font-size: 9px; color: var(--muted); }
.runtime-health-text { font-size: 9px; color: var(--nx-muted); }
.runtime-incident { margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.incident-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border: 1px solid rgba(229,176,94,.3); border-radius: 6px; background: rgba(229,176,94,.08); color: #e5b05e; font-size: 9px; }
@@ -209,7 +209,7 @@ h1 { margin: 7px 0 5px; font-size: 27px; letter-spacing: -.04em; }
.snapshot-agent-item .name { font-size: 10px; color: #e8eaf0; }
.snapshot-agent-item .role-tag { margin-left: auto; font-size: 8px; padding: 1px 6px; border: 1px solid #343947; border-radius: 6px; color: #8991a1; }
.status-dot--sm { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.status-dot--sm.online { background: var(--green); }
.status-dot--sm.online { background: var(--nx-green); }
.status-dot--sm.degraded { background: #e7b660; }
.status-dot--sm.offline { background: #e16e75; }
+6
View File
@@ -0,0 +1,6 @@
export interface Toast {
id: number
message: string
type: 'success' | 'error' | 'info'
durationMs: number
}
+49 -19
View File
@@ -1,19 +1,19 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import MissionCard from '../components/dashboard/MissionCard.vue'
import TaskCard from '../components/dashboard/TaskCard.vue'
import OperationsFeed from '../components/dashboard/OperationsFeed.vue'
import TeamNetwork from '../components/dashboard/TeamNetwork.vue'
import ChatPanel from '../components/dashboard/ChatPanel.vue'
import QueuePanel from '../components/dashboard/QueuePanel.vue'
import AgentModal from '../components/dashboard/AgentModal.vue'
import { useDashboardData } from '../composables/useDashboardData'
import type { AgentNodeData } from '../../composables/useDashboardData'
import type { AgentNodeData } from '../composables/useDashboardData'
const {
agents, missions, feedEntries, chatMessages,
irisBusy, irisFocus, irisRuntime, queue,
agents, openTasks, feedEntries, chatMessages,
irisBusy, irisFocus, queue,
getAgentRuntime, startRuntime, stopRuntime,
sendChat, removeQueueItem, moveQueueItem, changeQueuePriority,
sendChatMessage, removeQueueItem, moveQueueItem, changeQueuePriority,
} = useDashboardData()
const selectedAgent = ref<AgentNodeData | null>(null)
@@ -26,8 +26,6 @@ function onAgentSelect(id: string) {
onMounted(startRuntime)
onUnmounted(stopRuntime)
function onChatSend(text: string): void { sendChat(text) }
function onQueueMoveUp(id: string): void {
const idx = queue.value.findIndex(q => q.id === id)
if (idx > 0) moveQueueItem(idx, idx - 1)
@@ -48,8 +46,7 @@ function onQueueExecuteNow(id: string): void {
<div class="dashboard">
<div class="col-left">
<section class="missions-section">
<h2 class="column-title">Active Missions</h2>
<MissionCard v-for="m in missions" :key="m.id" :mission="m" />
<TaskCard :tasks="openTasks" @new-task="console.log('New task requested')" @go-board="console.log('Go to Task Board')" />
</section>
<OperationsFeed :entries="feedEntries" />
</div>
@@ -69,9 +66,6 @@ function onQueueExecuteNow(id: string): void {
<TeamNetwork
hero-id="iris"
:agents="agents"
:iris-runtime="irisRuntime"
:get-agent-runtime="getAgentRuntime"
:iris-focus="irisFocus"
@select="onAgentSelect"
/>
@@ -92,7 +86,7 @@ function onQueueExecuteNow(id: string): void {
</div>
</div>
<div class="col-right">
<ChatPanel :messages="chatMessages" :iris-busy="irisBusy" :iris-focus="irisFocus" @send="onChatSend" />
<ChatPanel :messages="chatMessages" :iris-busy="irisBusy" :iris-focus="irisFocus" />
<QueuePanel :items="queue" @remove="removeQueueItem" @move-up="onQueueMoveUp" @move-down="onQueueMoveDown" @change-priority="changeQueuePriority" @execute-now="onQueueExecuteNow" />
</div>
@@ -107,7 +101,9 @@ function onQueueExecuteNow(id: string): void {
<style scoped>
.dashboard {
display: grid; grid-template-columns: 280px 1fr 320px; gap: 14px;
display: grid;
grid-template-columns: 280px 1fr 320px;
gap: 14px;
height: 100%; min-height: 0;
animation: fade-in 0.35s ease-out;
}
@@ -124,7 +120,7 @@ function onQueueExecuteNow(id: string): void {
/* Quote Pill */
.quote-pill {
background: var(--panel);
background: var(--nx-panel);
border: 1px solid rgba(139, 124, 246, 0.25);
border-radius: 14px;
padding: 14px 22px;
@@ -169,8 +165,8 @@ function onQueueExecuteNow(id: string): void {
justify-content: center;
gap: 24px;
padding: 12px 20px;
background: var(--panel);
border: 1px solid var(--line);
background: var(--nx-panel);
border: 1px solid var(--nx-line);
border-radius: 10px;
}
.legend-item {
@@ -206,8 +202,42 @@ function onQueueExecuteNow(id: string): void {
.col-right { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-left: 4px; }
.missions-section { display: flex; flex-direction: column; gap: 8px; }
.column-title { margin: 0; font-size: 13px; font-weight: 600; color: #e8eaf0; letter-spacing: 0.01em; }
/* Tablet: 2 columns — left+center together, right column alongside */
@media (max-width: 1100px) {
.dashboard { grid-template-columns: 1fr; }
.col-left, .col-center, .col-right { overflow: visible; padding: 0; }
.dashboard {
grid-template-columns: 1fr 320px;
grid-template-rows: auto auto;
}
.col-left {
grid-column: 1;
grid-row: 1;
}
.col-center {
grid-column: 1;
grid-row: 2;
}
.col-right {
grid-column: 2;
grid-row: 1 / 3;
}
}
/* Mobile: 1 column, everything stacked */
@media (max-width: 768px) {
.dashboard {
grid-template-columns: 1fr;
gap: 8px;
}
.col-left, .col-center, .col-right {
grid-column: 1;
grid-row: auto;
overflow: visible;
padding: 0;
}
.quote-pill { padding: 10px 14px; }
.quote-text { font-size: 10px; }
.team-title { font-size: 20px; }
.legend-row { gap: 12px; padding: 8px 12px; flex-wrap: wrap; }
.legend-item { font-size: 8px; gap: 4px; }
}
</style>
+2 -2
View File
@@ -439,7 +439,7 @@ onMounted(loadDocs)
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
@@ -461,7 +461,7 @@ 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) {
+2 -2
View File
@@ -410,7 +410,7 @@ onMounted(loadIncidents)
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
overflow-x: auto;
}
.incident-rendered :deep(pre code) {
@@ -432,7 +432,7 @@ 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) {
+2 -2
View File
@@ -417,7 +417,7 @@ onMounted(loadMemories)
padding: 12px;
border-radius: 8px;
background: #0d1016;
border: 1px solid var(--line);
border: 1px solid var(--nx-line);
overflow-x: auto;
}
.memory-rendered :deep(pre code) {
@@ -439,7 +439,7 @@ 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) {
+5 -5
View File
@@ -282,8 +282,8 @@ onMounted(loadProject)
transition: all 0.15s;
}
.btn-icon:hover {
background: var(--accent-soft);
color: var(--accent);
background: var(--nx-accent-soft);
color: var(--nx-accent);
}
.btn-icon.btn-danger:hover {
background: rgba(239, 68, 68, 0.1);
@@ -321,7 +321,7 @@ onMounted(loadProject)
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.8rem;
background: var(--accent);
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 6px;
@@ -361,7 +361,7 @@ onMounted(loadProject)
.progress-bar i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-secondary));
background: linear-gradient(90deg, var(--nx-accent), var(--accent-secondary));
border-radius: 4px;
transition: width 0.3s;
}
@@ -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(--accent); }
.task-icon.in-progress { color: var(--nx-accent); }
.task-info {
flex: 1;
}
+3 -3
View File
@@ -231,7 +231,7 @@ async function changePassword() {
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
background: var(--accent);
background: var(--nx-accent);
color: #fff;
border: none;
border-radius: 8px;
@@ -267,8 +267,8 @@ async function changePassword() {
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
background: var(--accent-soft);
color: var(--accent);
background: var(--nx-accent-soft);
color: var(--nx-accent);
border-radius: 4px;
font-size: 0.8rem;
text-transform: uppercase;
+5 -1
View File
@@ -11,7 +11,11 @@
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+6
View File
@@ -2,12 +2,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
test: {
environment: 'node',
},
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/api': 'http://localhost:8080',