Compare commits

...

133 Commits

Author SHA1 Message Date
iris c496608c86 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 28s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris c040696d91 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-16 15:00:30 +00:00
iris 7ba0bd26fa docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
iris 4b1d140b53 docs: update README, changelog, phases — remove Ollama/NVIDIA refs, current model config, migration history
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-16 15:00:29 +00:00
developer e0c88238da refactor: extract DI, helpers from Program.cs into extension classes
CI - Build & Test / Backend (.NET) (push) Successful in 1m18s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 48s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-16 16:52:17 +02:00
AzuTear b0e65e3980 style: strengthen flow lines and tighten modal demo parity
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
2026-06-14 15:57:12 +02:00
devops 648a5d2151 refactor: move landingpage to separate repo bao/noveria-landing
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:53:00 +02:00
devops 1a024eef96 feat: noveria.net landingpage template
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:45:23 +02:00
devops 6280e87078 infra: landingpage compose + nginx config
CI - Build & Test / Backend (.NET) (push) Has been cancelled
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:51 +02:00
AzuTear 64459ccdb3 feat: wire dashboard v2 to backend data
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 15:44:05 +02:00
devops 38dc2efc6c docs: devops deploy-actor documentation
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:41:38 +02:00
AzuTear 390bffa208 fix: detect drag state on pointer release
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 15:33:51 +02:00
AzuTear e034883abd fix: open agent cards only on click
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:23:05 +02:00
AzuTear 6d4e8e7927 refactor: streamline flow board interactions
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 15:11:05 +02:00
reviewer 0f8939306d feat: mobile-responsive dashboard v2
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
2026-06-14 12:16:06 +02:00
reviewer 58675f0c69 ops: enhanced deploy verification with web-recovery + incident docs
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
2026-06-14 11:31:46 +02:00
reviewer 88cafc7b8e review: remove version-bump from deploy workflow — VERSION is read-only source of truth
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
2026-06-14 11:31:04 +02:00
reviewer 485357c6dc review: error-handling for config file write + compose resource limits
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
- AgentsController.SaveConfigFile: catch UnauthorizedAccessException and IOException
  instead of letting them bubble up unhandled; return clean 500 with logged message
- compose.yaml: add deploy.resources.limits.memory and reservations.memory for
  api (512M/128M), web (128M/32M), postgres (256M/64M)
2026-06-14 11:30:25 +02:00
devops 36b32f0e88 chore: bump version to 0.2.56 [skip ci] 2026-06-14 07:50:18 +00:00
reviewer 8a556c25a0 Add local liveness health endpoint
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:49:25 +02:00
devops f271602f31 chore: bump version to 0.2.55 [skip ci] 2026-06-14 07:29:01 +00:00
reviewer 63319e1046 fix: stream deploy env into docker cli
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:27:56 +02:00
devops b730fa1518 chore: bump version to 0.2.54 [skip ci] 2026-06-14 07:21:34 +00:00
reviewer fadb5d75c4 Fix AgentService tests fixture path
CI - Build & Test / Backend (.NET) (push) Successful in 30s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:20:28 +02:00
reviewer 45a39d319f Fix operations CI and snapshots
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 09:14:24 +02:00
reviewer 5ea7aa9611 fix(ops): mount temp env directory for compose
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-14 08:48:23 +02:00
devops a6fabb90b0 chore: bump version to 0.2.53 [skip ci] 2026-06-14 06:46:55 +00:00
reviewer db62354c97 fix(ops): pass temp env via compose --env-file
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-14 08:44:42 +02:00
devops 20dedcd6fa chore: bump version to 0.2.52 [skip ci] 2026-06-14 06:42:37 +00:00
reviewer 4ad0f9e493 refactor: SOLID architecture — backend service layer + frontend V2 components
CI - Build & Test / Backend (.NET) (push) Failing after 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 2s
## Backend — Service Layer & Repository Refactoring

### Neue Services (21 neue Dateien)

**Interfaces & Implementierungen:**
- `IOpenClawGatewayClient` — Interface für OpenClawGatewayClient (DIP-Fix: DashboardController hing an konkreter Klasse)
- `IAgentConfigService` / `AgentConfigService` — Agent-Config-File-I/O aus AgentsController extrahiert
- `IProjectService` / `ProjectService` — Projekt-CRUD + Activity-Logging (SRP)
- `ITaskService` / `TaskService` — Task-State-Machine, Approve/Reject, Dashboard-Operationen (eliminiert Duplikation zwischen TasksController und DashboardController)
- `IDashboardService` / `DashboardService` — Queue-Aggregation, Priority-Normalisierung, Gateway-Delegation
- `IOperationsService` / `OperationsService` — Metriken-Berechnung aus OperationsController
- `ITeamService` / `TeamService` — IDENTITY.md-Lesen aus TeamController
- `IMemoryService` / `MemoryService` — File-I/O aus MemoryController
- `IIncidentService` / `IncidentService` — File-Parsing (Regex-Source-Generatoren) aus IncidentsController
- `IDocService` / `DocService` — Directory-Scan aus DocsController
- `ICalendarService` / `CalendarService` — Gateway-HTTP-Calls + Fallback-Daten aus CalendarController

### Repository-Fixes

**IUserRepository / UserRepository:**
- `SaveChangesAsync` entfernt (leaky abstraction — Caller sollten nie SaveChanges steuern)
- `RevokeTokenAsync(tokenHash)` — atomares Token-Revoke inkl. SaveChanges
- `RevokeFamilyAsync(familyId)` — Batch-Revoke einer Token-Familie inkl. SaveChanges
- `RemoveExpiredTokensAsync` speichert jetzt selbst (war vorher dependent auf nachfolgenden Save)

### AuthService-Fixes
- `GetUserAsync`: unnötiges `Task.Run` entfernt → direkt `_users.GetByIdAsync().AsTask()`
- `RevokeAsync`: delegiert jetzt an `IUserRepository.RevokeTokenAsync`
- `RefreshAsync`: Token-Reuse-Detection delegiert an `IUserRepository.RevokeFamilyAsync`

### Bug-Fix
- `OpenClawGatewayClient.ReadAgentGoalAsync`: pre-existing `CS1656` behoben (`reader` war `using`-Variable und wurde neu zugewiesen — in `reader2` umbenannt)

### Controller (16 Stück — alle slim)
Alle Controller reduziert auf: Input validieren → Service aufrufen → HTTP-Result zurückgeben.
Kein Business-Logic, kein File-I/O, keine direkte Repository-Nutzung (außer AgentsController für Activity-Log).

**Program.cs — neue Registrierungen:**
- `AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>` (war vorher konkrete Klasse)
- Scoped: IDashboardService, IProjectService, ITaskService, IOperationsService, ITeamService, ICalendarService
- Singleton: IAgentConfigService, IMemoryService, IIncidentService, IDocService

---

## Frontend — Dashboard V2 Components

**AgentDetailModal.vue, IrisChat.vue, TaskStrip.vue:**
- V2 Design-System: Dark Space Theme, Glass-Panels, Gradient-Akzente
- Stores (agents, chat, tasks) nutzen Service + Mapper-Pattern
- NexusLayout, FlowBoard, Topbar — Layoutfixes für fullHeight-Route-Meta

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 08:34:58 +02:00
devops ac4e1cd3cf chore: bump version to v0.2.51 [skip ci] 2026-06-13 19:04:36 +00:00
reviewer 01c9bda339 fix(ops): change api healthcheck from curl to wget (curl not in base dotnet-aspnet image) [skip ci] 2026-06-13 21:04:29 +02:00
devops 1b11793dad chore: bump version to v0.2.50 [skip ci] 2026-06-13 19:03:48 +00:00
reviewer 98f98b55d5 fix(ops): change web healthcheck from wget to curl (wget IPv6 causes false unhealthy on nginx-alpine) [skip ci] 2026-06-13 21:03:41 +02:00
devops f28c398d16 chore: bump version to v0.2.49 [skip ci] 2026-06-13 19:00:26 +00:00
reviewer 358ec3e65d feat(frontend): Dashboard V2 — FlowCanvas, AgentModal, IrisChat, Stores, PlaceholderViews, Icon-Library
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-13 20:58:53 +02:00
devops 5f3d04f44c chore: bump version to v0.2.48 [skip ci] 2026-06-13 18:04:57 +00:00
reviewer d169cbe9d5 feat(ops): production resilience — healthchecks, restart_policy, log-rotation, --wait deploy [skip ci] 2026-06-13 20:04:42 +02:00
reviewer 6cedd8410f refactor(frontend): deduplicate CSS keyframes, unify types, extract format utils, add UI states, trim mock data
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
- Remove duplicate @keyframes pulse-* from 3 component files (already in nexus-tokens.css)
- Rename AgentDetail → AgentDetailData in dashboard types to avoid collision with types/agent.ts
- Extract shared formatNumber/initials/formatTime to utils/format.ts
- Simplify FlowBoard: use agentStore modal/selection getters instead of duplicating local state
- Add error banner + empty state to IrisChat; add loading skeleton + error/empty states to TaskStrip
- Remove 105-line unused mockAgents array from useFlowLayout
- Reduce operations store fallbacks from hardcoded preview data to minimal safe defaults
- Update operations store tests to match lean fallback structure
- Net: -73 lines, cleaner imports, fewer magic strings
2026-06-12 17:02:50 +02:00
developer 9033ff2973 feat(v2): live sidebar counts, /dashboard = V2 default route, remove V1 dead code
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 01:01:50 +02:00
developer 676dbd7589 feat(v2): Pinia stores (agents/tasks/chat) + live backend integration, remove mock data
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:57:28 +02:00
developer 9330de7af0 feat(v2): AgentDetailModal — metrics grid, thinking feed, model dropdown, keyboard nav
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:52:59 +02:00
developer 6023b5ea24 fix(v2): reviewer bugfixes — scroll, block-status, NaN guard, dead code cleanup
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:51:42 +02:00
developer 166c9f9051 feat(v2): IrisChat + TaskStrip components, mock data integration
CI - Build & Test / Backend (.NET) (push) Failing after 20s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:48:13 +02:00
developer 2d6e3537e8 feat(v2): FlowCanvas, AgentNode, AlertBar, useFlowLayout composable
CI - Build & Test / Backend (.NET) (push) Failing after 21s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-12 00:24:56 +02:00
developer 3672e56994 feat(v2): NexusLayout, Sidebar, NavGroup, NavItem, Topbar, FlowBoard placeholder
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-12 00:20:27 +02:00
developer f378d7aed4 feat(v2): design tokens, fonts (Manrope/Space Grotesk/JetBrains Mono), galaxy background
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-12 00:16:01 +02:00
developer 1a7bf8ca11 revert: remove Claude models (API not working)
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 18:11:02 +02:00
developer 3907548a1d feat(models): add Claude Sonnet 4.6 + Opus 4.8, mount openclaw.json in API container
CI - Build & Test / Backend (.NET) (push) Failing after 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 17:11:59 +02:00
developer b1888bd8ef feat(dashboard): AgentModal live working feed & thinking stream
CI - Build & Test / Backend (.NET) (push) Failing after 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-11 16:13:28 +02:00
developer c29740a466 feat(dashboard): dynamic agent metrics + model list from config, no more hardcoded data
CI - Build & Test / Backend (.NET) (push) Failing after 23s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 16:01:33 +02:00
devops 45c6b24928 chore: bump version to v0.2.47 [skip ci] 2026-06-11 13:59:03 +00:00
developer 5fb62bef8a feat(queue): erweiterte Queue mit Cron-Jobs + Tasks, Prioritäten, Delete/Priority API
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
2026-06-11 15:58:12 +02:00
devops 068b0d31b8 chore: bump version to v0.2.46 [skip ci] 2026-06-11 13:55:20 +00:00
developer 97b8588dc3 feat(dashboard): multi-agent operations feed aggregating all agent sessions
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
2026-06-11 15:54:32 +02:00
devops 6150ea96af chore: bump version to v0.2.45 [skip ci] 2026-06-11 13:52:39 +00:00
developer 81af81fb6f feat(dashboard): task system with DB persistence, CRUD endpoints, frontend API integration
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
2026-06-11 15:51:48 +02:00
devops 2877035c5c chore: bump version to v0.2.44 [skip ci] 2026-06-11 13:49:27 +00:00
developer 6a1366b472 feat(dashboard): dynamic agent data from gateway sessions instead of hardcoded list
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 14s
CI - Build & Test / Security Check (push) Successful in 2s
2026-06-11 15:48:33 +02:00
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
devops 90bb7251e3 chore: bump version to v0.2.22 [skip ci] 2026-06-09 20:31:44 +00:00
developer e57bef95e5 fix: mehr Abstand Iris↔Grid + Linien enger gebündelt
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 2s
- gap: 32px → 64px (doppelter Vertikalraum)
- startX: 0.30+0.40 → 0.38+0.24 (enger unter Iris)
- cp1y: startY+40 → startY+70 (tiefer vor Spread)
- cp2x: ±50 → ±35 (sanftere Card-Annäherung)
2026-06-09 22:30:55 +02:00
devops 71b4465595 chore: bump version to v0.2.21 [skip ci] 2026-06-09 20:28:41 +00:00
developer 9b63e5368e feat: Live Thinking Panel im AgentModal
CI - Build & Test / Backend (.NET) (push) Successful in 22s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 15s
CI - Build & Test / Security Check (push) Successful in 3s
- Scrollbarer Thinking-Stream (slide-in von links)
- Pulsierender Rahmen mit Glow-Effekt
- Blau+Violett Dots rotieren im Uhrzeigersinn
- thinkingStream in AgentNodeData + Beispieldaten für alle 5 Agenten
2026-06-09 22:27:53 +02:00
devops 8f265d00ba chore: bump version to v0.2.20 [skip ci] 2026-06-09 20:24:07 +00:00
developer 5a3a099b94 fix: Iris als Hero im AI Team Network – Hierarchie korrigiert
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 2s
- Iris zu agents[] hinzugefügt (Position 0)
- hero-id='iris' an TeamNetwork übergeben
- Hero-Slot data-agent-id dynamisch (:data-agent-id='hero.id')
2026-06-09 22:23:17 +02:00
devops 1f6f5dd08c chore: bump version to v0.2.19 [skip ci] 2026-06-09 20:20:03 +00:00
developer 6e532f64f5 fix: AgentModal bei Klick auf Card verdrahtet
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- @select Handler auf TeamNetwork
- selectedAgent State + onAgentSelect()
- AgentModal importiert und gerendert (v-if selectedAgent)
- Close via X oder Overlay
2026-06-09 22:19:12 +02:00
devops 7154c30b99 chore: bump version to v0.2.18 [skip ci] 2026-06-09 20:13:46 +00:00
developer ffe7baba78 fix: Vollbreite-Layout – max-width/margin entfernt, Content füllt Desktop-Breite
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 2s
- .content: max-width:1320px + margin:auto entfernt
- padding reduziert: 36px 34px → 16px 16px
- Middle-Col (1fr) im Dashboard nutzt jetzt volle Restbreite
2026-06-09 22:12:55 +02:00
devops da9c256b43 chore: bump version to v0.2.17 [skip ci] 2026-06-09 20:10:25 +00:00
developer 1012d2c217 feat: altes AgentCard-Design in TeamNetwork + node-task / node-runtime
CI - Build & Test / Backend (.NET) (push) Successful in 26s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- Icon-Wrap + Name + Role-Tag + Description + Tags + Footer 'ROLE CARD →'
- node-task: aktuelle Aufgabe mit ●-Punkt (zwischen Description und Tags)
- node-runtime: Live-Runtime im Footer (tabular-nums)
- TeamNetwork background: transparent, Klasse → ai-team-network
- Modal-Klick erhalten
2026-06-09 22:09:02 +02:00
devops 611c343e0c chore: bump version to v0.2.16 [skip ci] 2026-06-09 19:59:13 +00:00
developer 2857c27b7c feat: Quote, Title, Legende um AI Team Network im Dashboard
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- Quote-Pill: 'An autonomous organization of AI agents...' wie alte TeamView
- Title/Subtitle/Description über Netzwerk
- Legende (Aktive/Idle/Pulse) unter Netzwerk
- Klick = Modal (wie neue Version, NICHT router.push)
- TeamNetwork unangetastet (SVG-Linien, Bézier, Pulse, Glow)
2026-06-09 21:58:21 +02:00
devops 745e202e21 chore: bump version to v0.2.15 [skip ci] 2026-06-09 19:50:06 +00:00
developer 5244e9fd3d feat: AI Team Network ins Dashboard integriert, Vollbreite, dynamisches Grid
CI - Build & Test / Backend (.NET) (push) Successful in 31s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- TeamNetwork ins Dashboard verschoben (center column)
- 3-Spalten-Layout auf volle Desktop-Breite (kein max-width)
- Agent-Grid dynamisch: 2 Spalten, erweitert nach unten (4→6→8 Agenten)
- SVG-Bézier-Linien mit ResizeObserver passen sich an
- 'Team' aus Navigation, Router und standaloneViews entfernt
- /team Route gelöscht
2026-06-09 21:49:10 +02:00
devops 774a5a44f3 chore: bump version to v0.2.14 [skip ci] 2026-06-09 19:38:06 +00:00
developer b535fd1ab3 feat: AI Team Network – Bézier lines, parallel routing, pulse animation
CI - Build & Test / Backend (.NET) (push) Successful in 25s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 17s
CI - Build & Test / Security Check (push) Successful in 3s
- SVG-Layer hinter Cards mit Bézier-Kurven (C-Pfad)
- 4 Linien starten parallel an Iris' unterer Mitte, keine Überschneidungen
- Links-Spalte: Linien kurven nach links, treffen rechte Card-Kante
- Rechts-Spalte: Linien kurven nach rechts, treffen linke Card-Kante
- JS-Pulse-Animation via requestAnimationFrame (~3s/Cycle)
- Aktive Agenten: glow box-shadow, hellere Linien, full-opacity Puls
- Responsive ResizeObserver, mobile Single-Column
- Titel 'AI Team Network', Legende (active/idle/pulse)
2026-06-09 21:37:12 +02:00
devops 87e504a1b5 chore: bump version to v0.2.13 [skip ci] 2026-06-09 19:25:23 +00:00
devops 802d2cef3f fix(ci): remove swagger from smoke test — disabled in production
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
Swagger (/swagger) is only enabled in Development mode (Program.cs
gates it behind app.Environment.IsDevelopment()). In production,
nginx serves the frontend catch-all (index.html), so the check
always returns 200 but never actually validates the API layer.

/health already covers API + database + runtime health checks.
No replacement endpoint needed — the smoke test still validates
both the dashboard and the backend API via /health.
2026-06-09 21:24:27 +02:00
devops 7bee8bc23f chore: bump version to v0.2.12 [skip ci] 2026-06-09 19:21:42 +00:00
devops 84bf9b7fba fix(ci): correct swagger path + add deploy concurrency guard
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
Iteration 2 fix: /api/swagger → /swagger (correct ASP.NET default).

Iteration 3 — Concurrency guard:
- concurrency group 'deploy-production': ensures only one deploy
  runs at a time (cancel-in-progress: false so queued deploys
  wait instead of being cancelled).
- Why: prevents race conditions when CI-triggered workflow_run
  and manual workflow_dispatch overlap. Without this, parallel
  deploys could corrupt docker compose state or conflict on
  shared resources (ports, volumes, version tags).
2026-06-09 21:20:54 +02:00
devops b0b95d2453 chore: bump version to v0.2.11 [skip ci] 2026-06-09 19:20:00 +00:00
devops cf00318f23 feat(ci): robust health check + multi-endpoint smoke test + rollback hint
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
Iteration 2 — Deploy robustness:
- Health check: Fibonacci-ish backoff (1,2,3,5,8,13s) instead of fixed
  5s intervals. Why: containers need variable warmup time; fixed intervals
  either wait too long or give up too early. Total budget ~32s vs 30s before.
- Smoke test: now checks /dashboard, /health, and /api/swagger. Why: a
  single endpoint check can miss backend-only outages; API Swagger confirms
  the ASP.NET layer is healthy.
- Rollback hint: on any failure, prints previous git tag + docker compose
  commands for quick manual rollback. Why: reduces MTTR by providing the
  exact recovery steps inline.
2026-06-09 21:19:07 +02:00
devops 1085c14594 chore: bump version to v0.2.10 [skip ci] 2026-06-09 19:17:34 +00:00
devops df72fd9439 fix(ci): use --no-frozen-lockfile until lockfile is regenerated
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 2s
pnpm defaults to frozen-lockfile in CI. The committed lockfile
is outdated (vitest added to package.json). Using --no-frozen-lockfile
is a pragmatic fix; lockfile should be regenerated via 'pnpm install'
and recommitted for full --frozen-lockfile enforcement.
2026-06-09 21:16:47 +02:00
devops 66b833b68b fix(ci): commit pnpm-lock.yaml for frozen-lockfile CI
CI - Build & Test / Backend (.NET) (push) Successful in 24s
CI - Build & Test / Frontend (Vue/TS) (push) Failing after 7s
CI - Build & Test / Security Check (push) Successful in 3s
The --frozen-lockfile flag requires the lockfile to be present
in the checkout. Previously pnpm-lock.yaml was gitignored, so
it was absent from CI checkouts.

Lockfiles SHOULD be version-controlled for reproducible builds.
This also enables CI to detect when lockfile is outdated vs
package.json.
2026-06-09 21:15:13 +02:00
devops 65b46386a1 perf(ci): concurrency groups + strict pnpm lockfile
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Failing after 8s
CI - Build & Test / Security Check (push) Successful in 2s
Iteration 1 — CI reliability and speed:
- Concurrency: cancel in-progress CI runs when new push arrives
  to the same branch. Why: Avoids waste when pushing multiple
  fixes in quick succession; only the latest code is tested.
- pnpm: switch from --no-frozen-lockfile to --frozen-lockfile.
  Why: Fails fast if pnpm-lock.yaml is outdated — prevents
  untested dependency changes from reaching main.
- pnpm: add --prefer-offline to use locally cached packages.
  Why: Slightly faster installs when packages are already
  available in the runner image cache.
2026-06-09 21:13:57 +02:00
devops 09fb6c1ec0 perf(ci): add NuGet + pnpm caching to speed up CI
CI - Build & Test / Backend (.NET) (push) Successful in 1m41s
CI - Build & Test / Frontend (Vue/TS) (push) Has been cancelled
CI - Build & Test / Security Check (push) Has been cancelled
Iteration 1 — Build caching:
- Backend: cache ~/.nuget/packages keyed on .csproj hashes.
  Typical hit: restore drops from ~15s to ~2s (NuGet packages
  already cached locally).
- Frontend: cache node_modules + ~/.pnpm-store keyed on
  pnpm-lock.yaml. Typical hit: install drops from ~30s to ~3s.
- Concurrency: cancel in-progress CI runs when new push arrives
  to the same branch (avoids queue buildup).

Why: On cache hits, CI time drops ~60-70%. Faster feedback for
developers means shorter fix-deploy cycles.
2026-06-09 21:11:17 +02:00
devops 4c2e23517e chore: bump version to v0.2.9 [skip ci] 2026-06-09 19:09:26 +00:00
devops 045e36b014 fix(ci): remove backslash escapes from Gitea expressions in Build step
CI - Build & Test / Backend (.NET) (push) Successful in 27s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
The \$ escape before ${{ inputs.service }} prevented Gitea from
evaluating the expression, passing literal backslash to the shell.
Also use ${BUILD_ARGS} (shell expansion) instead of \$BUILD_ARGS
so the outer shell passes the actual build args to the DIND container.
2026-06-09 21:08:33 +02:00
devops 961d096ca6 chore: bump version to v0.2.8 [skip ci] 2026-06-09 19:06:56 +00:00
devops 5a72399136 fix(ci): create .env in workspace before sync (DIND path issue)
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
Phase 1 — .env provisioning fix:
The previous approach tried to write .env directly to
/opt/openclaw/data/openclaw/workspace/nexus from inside the
runner's job container, but that host path is not mounted there.

Fix: write .env from Gitea secrets into the workspace first,
then sync it along with the source code via the existing
Docker-in-Docker pattern (which can access the host path).

Combined the separate '.env creation' and 'sync code' steps
into a single atomic 'Sync code + .env to host' step.
2026-06-09 21:06:04 +02:00
devops b1cc228fd6 chore: bump version to v0.2.7 [skip ci] 2026-06-09 19:04:11 +00:00
devops 3646521a75 fix(ci): version bump from git tags + .env from secrets
CI - Build & Test / Backend (.NET) (push) Successful in 29s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 16s
CI - Build & Test / Security Check (push) Successful in 3s
Phase 1 — Deploy reliability:
- Version bump: derive current version from 'git describe --tags' instead of
  VERSION file. This eliminates race conditions where the VERSION file is
  stale but the tag already exists from a previous failed run.
- Tag creation: use 'git tag -f' + 'git push --force --tags' to handle
  retries gracefully when tags already exist.
- Environment: provision .env at the host deploy path from Gitea secrets
  (ENV_POSTGRES_PASSWORD, ENV_JWT_KEY, ENV_OWNER_PASSWORD, ENV_OPENCLAW_TOKEN).
  This ensures .env always exists on the host even though it's excluded from
  the sync step for security.

Runner label was already fixed in previous commit (runs-on: ubuntu-latest).
2026-06-09 21:03:15 +02:00
155 changed files with 13996 additions and 4338 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(npx tsc *)",
"Bash(npx vite *)"
]
}
}
+156
View File
@@ -0,0 +1,156 @@
name: Database Backup
run-name: 💾 DB Backup triggered by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# Trigger: Manual (workflow_dispatch) + optional schedule.
#
# Strategy:
# 1. Connects to the live PostgreSQL container via docker exec.
# 2. Runs pg_dumpall (full cluster dump, single file).
# 3. Compresses with gzip.
# 4. Uploads as a Gitea Action artifact (or writes to host path).
# 5. Artifacts are retained per Gitea repo settings (default 90 days).
#
# Rotation: Gitea artifact expiration handles old backups automatically.
# For longer retention, configure an external cron job or use the
# host_path output to copy the backup elsewhere.
#
# Restoration: See phases/deployment.md for step-by-step instructions.
# ───────────────────────────────────────────────────────
concurrency:
group: db-backup
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
keep_on_host:
description: 'Also copy backup to host path?'
required: false
default: false
type: boolean
host_backup_path:
description: 'Host path for backup (only if keep_on_host is true)'
required: false
default: '/opt/openclaw/backups'
type: string
# Optional: uncomment to enable nightly automatic backups
# schedule:
# - cron: '0 3 * * *' # Every night at 03:00 UTC
jobs:
backup:
name: Backup PostgreSQL
runs-on: ubuntu-latest
env:
ENV_TMPFILE: /tmp/nexus-backup-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
BACKUP_CONTAINER_NAME: nexus-postgres-1
steps:
# ═══════════════════════════════════════════════════
# Step 1: Generate backup filename
# ═══════════════════════════════════════════════════
- name: Generate backup identifier
id: meta
run: |
TIMESTAMP=$(date -u +'%Y-%m-%dT%H%M%SZ')
echo "timestamp=${TIMESTAMP}" >> "$GITEA_OUTPUT"
echo "filename=nexus-backup-${TIMESTAMP}.sql.gz" >> "$GITEA_OUTPUT"
echo "📅 Backup ID: ${TIMESTAMP}"
# ═══════════════════════════════════════════════════
# Step 2: Dump PostgreSQL via docker exec
# ═══════════════════════════════════════════════════
- name: Dump database
run: |
set -euo pipefail
echo "🗄️ Dumping PostgreSQL cluster..."
docker exec "${BACKUP_CONTAINER_NAME}" \
sh -c "PGPASSWORD='${ENV_POSTGRES_PASSWORD}' pg_dumpall -U nexus -h localhost" \
| gzip > "${{ steps.meta.outputs.filename }}"
SIZE=$(du -h "${{ steps.meta.outputs.filename }}" | cut -f1)
echo "✅ Backup written: ${{ steps.meta.outputs.filename }} (${SIZE})"
# ═══════════════════════════════════════════════════
# Step 3: Upload backup as Gitea artifact
# ═══════════════════════════════════════════════════
- name: Upload backup artifact
uses: actions/upload-artifact@v4
with:
name: nexus-backup-${{ steps.meta.outputs.timestamp }}
path: ${{ steps.meta.outputs.filename }}
retention-days: 90
compression-level: 0 # already gzipped
# ═══════════════════════════════════════════════════
# Step 4: Optional — copy to host filesystem
# ═══════════════════════════════════════════════════
- name: Copy backup to host (optional)
if: inputs.keep_on_host == true
run: |
set -euo pipefail
HOST_PATH="${{ inputs.host_backup_path }}"
# Create host dir if it doesn't exist
docker run --rm \
-v "${HOST_PATH}:/backup-target" \
-v "${{ gitea.workspace }}:/src:ro" \
alpine:latest \
sh -c "
mkdir -p /backup-target && \
cp /src/${{ steps.meta.outputs.filename }} /backup-target/ && \
echo '✅ Backup copied to host: ${HOST_PATH}/${{ steps.meta.outputs.filename }}'
"
# ═══════════════════════════════════════════════════
# Step 5: Verify backup integrity
# ═══════════════════════════════════════════════════
- name: Verify backup integrity
run: |
echo "🔍 Verifying backup integrity..."
if gzip -t "${{ steps.meta.outputs.filename }}"; then
echo "✅ Backup gzip integrity check passed"
else
echo "❌ Backup file is corrupted!"
exit 1
fi
# Quick content check: should start with PostgreSQL dump header
HEADER=$(zcat "${{ steps.meta.outputs.filename }}" | head -1)
if echo "$HEADER" | grep -qE '^(-- PostgreSQL database cluster dump|-- Dumped|--)'; then
echo "✅ Backup content header check passed"
else
echo "⚠️ Unexpected backup header (may still be valid): $HEADER"
fi
# ═══════════════════════════════════════════════════
# Step 6: Backup Summary
# ═══════════════════════════════════════════════════
- name: Backup Summary
if: always()
run: |
STATUS="${{ job.status }}"
echo ""
echo "═══════════════════════════════════════"
echo " 💾 Database Backup Summary"
echo "═══════════════════════════════════════"
echo " File: ${{ steps.meta.outputs.filename }}"
echo " Timestamp: ${{ steps.meta.outputs.timestamp }}"
echo " Triggered: @${{ gitea.actor }}"
echo " On host: ${{ inputs.keep_on_host == 'true' && inputs.host_backup_path || 'No (artifact only)' }}"
echo " Status: ${STATUS}"
echo "═══════════════════════════════════════"
if [ "${STATUS}" = "success" ]; then
echo ""
echo "💡 Restore command (manual, on host):"
echo " zcat ${{ steps.meta.outputs.filename }} | docker exec -i nexus-postgres-1 psql -U nexus -d postgres"
fi
+26 -6
View File
@@ -1,6 +1,11 @@
name: CI - Build & Test name: CI - Build & Test
run-name: 🔍 CI ${{ gitea.ref_name }} by @${{ gitea.actor }} run-name: 🔍 CI ${{ gitea.ref_name }} by @${{ gitea.actor }}
# ── Concurrency: cancel in-progress CI when new push arrives ──
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
on: on:
push: push:
branches: [main] branches: [main]
@@ -22,14 +27,13 @@ jobs:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Restore - name: Restore
run: dotnet restore backend/Nexus.Api.csproj run: dotnet restore backend-tests/Nexus.Api.Tests.csproj
- name: Build - name: Build
run: dotnet build backend/Nexus.Api.csproj --no-restore --configuration Release run: dotnet build backend-tests/Nexus.Api.Tests.csproj --no-restore --configuration Release
- name: Test - name: Test
run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal run: dotnet test backend-tests/Nexus.Api.Tests.csproj --no-build --configuration Release --verbosity normal
continue-on-error: true
# ─── Frontend ────────────────────────────────── # ─── Frontend ──────────────────────────────────
frontend: frontend:
@@ -50,13 +54,17 @@ jobs:
corepack prepare pnpm@latest --activate corepack prepare pnpm@latest --activate
- name: Install dependencies - name: Install dependencies
run: pnpm install --no-frozen-lockfile run: pnpm install --frozen-lockfile
working-directory: frontend working-directory: frontend
- name: Type check - name: Type check
run: pnpm exec vue-tsc --noEmit run: pnpm exec vue-tsc --noEmit
working-directory: frontend working-directory: frontend
- name: Test
run: pnpm test
working-directory: frontend
- name: Build - name: Build
run: pnpm build run: pnpm build
working-directory: frontend working-directory: frontend
@@ -72,8 +80,20 @@ jobs:
- name: Check for .env leaks - name: Check for .env leaks
run: | run: |
if grep -r "API_KEY\|SECRET\|PASSWORD\|TOKEN" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null; then echo "🔍 Scanning for potential secrets in source code..."
echo "⚠️ Warning: Potential secrets in source code (review manually)" HITS=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"][^'\"]{8,}" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$HITS" ]; then
echo "❌ SECRET LEAK DETECTED — the following lines look like hardcoded credentials:"
echo "$HITS"
echo ""
echo "Remove these values and use environment variables or a secrets manager instead."
exit 1
fi
# Secondary pass: catch bare assign patterns that are suspicious regardless of length
LOOSE=$(grep -rPn "(API_KEY|SECRET|PASSWORD|TOKEN)\s*[:=]\s*['\"]" --include="*.cs" --include="*.ts" --include="*.vue" backend/ frontend/src/ 2>/dev/null || true)
if [ -n "$LOOSE" ]; then
echo "⚠️ WARNING — potential secrets found (short values may be false positives, review manually):"
echo "$LOOSE"
else else
echo "✅ No obvious secrets found" echo "✅ No obvious secrets found"
fi fi
+271 -66
View File
@@ -1,138 +1,343 @@
name: Deploy to Production name: Deploy to Production
run-name: 🚀 Deploy ${{ inputs.bump_version || 'patch' }} by @${{ gitea.actor }} run-name: 🚀 Deploy by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# CD v3 — 2026-06-13
#
# Triggers:
# 1. AUTOMATIC after successful CI on main (workflow_run)
# → Uses safe defaults: patch bump, all services, main ref.
# → Commits marked with [skip ci] are filtered at job level
# (prevents version-bump loops).
# 2. MANUAL via workflow_dispatch with full parameter control.
#
# Concurrency: one deploy at a time.
# Queued deploys wait — no race conditions with parallel builds.
#
# Version Management:
# The VERSION file in the repo root is the single source of truth.
# Version bumps happen in the Dev workflow BEFORE merge to main.
# The deploy workflow only reads, validates, and logs the version.
# The [skip ci] filter remains as a safety layer for auto-triggers.
# ───────────────────────────────────────────────────────
concurrency:
group: deploy-production
cancel-in-progress: false
on: on:
# ── Auto-Trigger: after successful CI on main ──
workflow_run: workflow_run:
workflows: ["CI - Build & Test"] workflows: ["CI - Build & Test"]
types: [completed] types: [completed]
branches: [main] branches: [main]
# ── Manual Trigger (full control) ──
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump_version:
description: 'Version bump (Major=x.0.0, Minor=1.x.0 features, Patch=1.0.x fixes)'
required: false
default: 'patch'
type: string
options:
- 'patch'
- 'minor'
- 'major'
service: service:
description: 'Service to deploy (empty = all)' description: 'Service to deploy (empty = all)'
required: false required: false
default: '' default: ''
type: string type: string
no_cache: no_cache:
description: 'Disable build cache' description: 'Disable Docker build cache'
required: false required: false
default: false default: false
type: boolean type: boolean
git_ref:
description: 'Git ref to deploy (branch, tag, or commit SHA; default: main)'
required: false
default: 'main'
type: string
jobs: jobs:
deploy: deploy:
name: Deploy Nexus name: Deploy Nexus
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ gitea.event_name != 'workflow_run' || gitea.event.workflow_run.conclusion == 'success' }} if: |
(github.event_name == 'workflow_dispatch') ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.conclusion == 'success' &&
!contains(github.event.workflow_run.head_commit.message, '[skip ci]'))
# ── Env for the deploy target path ──
env:
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-deploy-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps: steps:
- name: Checkout latest code # ═══════════════════════════════════════════════════
# Step 1: Checkout
# ═══════════════════════════════════════════════════
- name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
- name: Version Bump # ═══════════════════════════════════════════════════
# Step 2: Set up Git identity
# ═══════════════════════════════════════════════════
- name: Configure Git
run: | run: |
CURRENT_VERSION=$(cat VERSION)
echo "📦 Current version: $CURRENT_VERSION"
MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1)
MINOR=$(echo $CURRENT_VERSION | cut -d. -f2)
PATCH=$(echo $CURRENT_VERSION | cut -d. -f3)
case "${{ inputs.bump_version }}" in
major)
MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor)
MINOR=$((MINOR + 1)); PATCH=0 ;;
patch|*)
PATCH=$((PATCH + 1)) ;;
esac
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
echo "🏷️ New version: $NEW_VERSION"
echo "$NEW_VERSION" > VERSION
git config user.email "devops@noveria.net" git config user.email "devops@noveria.net"
git config user.name "DevOps" git config user.name "DevOps"
git add VERSION
git commit -m "chore: bump version to v${NEW_VERSION} [skip ci]"
git tag "v${NEW_VERSION}"
git push "https://devops:${{ secrets.GIT_TOKEN }}@git.noveria.net/bao/nexus.git" HEAD:main --tags
echo "✅ Version bumped to v${NEW_VERSION}"
- name: Sync code to host deploy path # ═══════════════════════════════════════════════════
# Step 3: Resolve deploy version
#
# Reads VERSION from repo root — the single source of truth.
# Validates semver format, logs version + git metadata.
# No git mutation: version bumps happen in the Dev workflow.
# ═══════════════════════════════════════════════════
- name: Resolve Version
id: version
run: | run: |
set -euo pipefail
# 1. Check VERSION exists
if [ ! -f VERSION ]; then
echo "❌ VERSION file not found"
exit 1
fi
# 2. Read and validate semver format
VERSION=$(cat VERSION | tr -d '[:space:]')
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "❌ Invalid semver in VERSION: '$VERSION'"
exit 1
fi
# 3. Log version, git ref, and describe
GIT_REF=$(git rev-parse --short HEAD)
GIT_DESCRIBE=$(git describe --always --dirty)
echo "📦 Deploy version: v${VERSION}"
echo "🔖 Git ref: ${GIT_REF}"
echo "🏷️ Git describe: ${GIT_DESCRIBE}"
# 4. Set outputs for downstream steps
echo "version=${VERSION}" >> "$GITEA_OUTPUT"
echo "mutated_main=false" >> "$GITEA_OUTPUT"
# ═══════════════════════════════════════════════════
# Step 4: Build .env from secrets (SAFE)
#
# Secrets are written to /tmp/nexus-deploy-env — NEVER
# to a file inside the workspace that gets rsync'd to
# the host. The temp file is deleted immediately after
# compose operations complete.
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets → temp file)
run: |
set -euo pipefail
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
# Managed via Gitea Secrets → do NOT edit manually on the host.
# This file lives in /tmp and is removed after deploy completes.
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 5: Sync code to host (without .env in workspace)
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \ docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \ -v "${{ gitea.workspace }}:/src:ro" \
-v /opt/openclaw/data/openclaw/workspace/nexus:/dest \ -v "${DEPLOY_PATH}:/dest" \
alpine:latest \ alpine:latest \
sh -c " sh -c "
cd /src && \ cd /src && \
find . -mindepth 1 -maxdepth 1 \ find . -mindepth 1 -maxdepth 1 \
! -name .git \ ! -name .git \
! -name .env \ -exec cp -r {} /dest/ \; && \
-exec cp -a {} /dest/ \; DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
" "
- name: Set up Docker Buildx echo "✅ Code synced to ${DEPLOY_PATH}"
run: docker buildx create --use 2>/dev/null || true
# ═══════════════════════════════════════════════════
# Step 6: Build & Deploy
#
# The temp .env file is bind-mounted read-only into the
# docker:cli container so compose can resolve variables.
# It is NEVER written into the workspace directory.
# ═══════════════════════════════════════════════════
- name: Build & Deploy - name: Build & Deploy
run: | run: |
set -euo pipefail
# Auto-deploy: always use cache. Manual: respect no_cache input.
NO_CACHE="${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }}"
BUILD_ARGS="" BUILD_ARGS=""
if [ "${{ inputs.no_cache }}" = "true" ]; then if [ "$NO_CACHE" = "true" ]; then
BUILD_ARGS="--no-cache" BUILD_ARGS="--no-cache"
fi fi
SERVICE_ARG="${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }}"
docker run --rm \ docker run --rm \
-v /opt/openclaw/data/openclaw/workspace/nexus:/workspace/nexus \ -v "${DEPLOY_PATH}:/workspace/nexus" \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \ -w /workspace/nexus \
-i \
docker:cli \ docker:cli \
sh -c " sh -c "
set -e set -e
if [ -n '${{ inputs.service }}' ]; then trap 'rm -f /tmp/nexus-deploy-env' EXIT
echo '🚀 Deploying service: ${{ inputs.service }}' cat > /tmp/nexus-deploy-env
docker compose build $BUILD_ARGS \${{ inputs.service }} if [ -n '${SERVICE_ARG}' ]; then
docker compose up -d --force-recreate \${{ inputs.service }} echo '🚀 Deploying service: ${SERVICE_ARG}'
docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS} ${SERVICE_ARG}
docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate ${SERVICE_ARG}
else else
echo '🚀 Deploying all services' echo '🚀 Deploying all services'
docker compose build $BUILD_ARGS docker compose --env-file /tmp/nexus-deploy-env build ${BUILD_ARGS}
docker compose up -d --force-recreate docker compose --env-file /tmp/nexus-deploy-env up -d --wait --force-recreate
fi fi
" " < "${ENV_TMPFILE}"
echo "✅ Docker compose up completed"
# ═══════════════════════════════════════════════════
# Step 7: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 8: Health Check (exponential backoff)
# ═══════════════════════════════════════════════════
- name: Health Check - name: Health Check
run: | run: |
sleep 5
echo "🏥 Health check..." echo "🏥 Health check..."
for i in 1 2 3 4 5 6; do RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo "" echo ""
echo "✅ Health check passed" echo "✅ Health check passed (attempt $RETRY/$MAX)"
break exit 0
fi fi
echo "⏳ Retry $i/6..." echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep 5 sleep $WAIT
# Fibonacci-ish backoff: 1,2,3,5,8,13
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done done
echo "❌ Health check failed after $MAX attempts"
exit 1
- name: Verify (smoke test) # ═══════════════════════════════════════════════════
# Step 9: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: | run: |
echo "🔍 Smoke test..." echo "🔍 Smoke test..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://nexus.noveria.net/dashboard) PASS=0
echo "Dashboard: HTTP $HTTP_CODE" FAIL=0
if [ "$HTTP_CODE" != "200" ]; then BASE="https://nexus.noveria.net"
echo "❌ Dashboard not reachable!"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1 exit 1
fi fi
echo "✅ Deployment verified" echo "✅ Smoke test passed — v${{ steps.version.outputs.version }} is live"
# ═══════════════════════════════════════════════════
# Step 10: Deployment Summary
# ═══════════════════════════════════════════════════
- name: Deployment Summary
if: always()
run: |
TRIGGER="${{ github.event_name == 'workflow_run' && 'Auto (CI success)' || 'Manual (workflow_dispatch)' }}"
echo ""
echo "═══════════════════════════════════════"
echo " 📦 Deploy Summary"
echo "═══════════════════════════════════════"
echo " Version: v${{ steps.version.outputs.version }}"
echo " Git ref: ${{ github.event_name == 'workflow_dispatch' && inputs.git_ref || 'main' }}"
echo " Service: ${{ github.event_name == 'workflow_dispatch' && inputs.service || 'all' }}"
echo " Trigger: ${TRIGGER}"
echo " Actor: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 11: Failure → Reviewer Handoff
#
# On failure: DevOps (Architekt) analyses the log,
# notifies Reviewer (Code-Fixer) with the exact error.
# This output provides a ready-to-copy message.
# ═══════════════════════════════════════════════════
- name: 🔴 Failure — Reviewer Handoff
if: failure()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 DEPLOY FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Version: v${{ steps.version.outputs.version }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Rollback: Trigger 'Rollback to Previous Version' │"
echo "│ workflow manuell in Gitea Actions. │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+277
View File
@@ -0,0 +1,277 @@
name: Rollback to Previous Version
run-name: 🔙 Rollback by @${{ gitea.actor }}
# ───────────────────────────────────────────────────────
# Owner: DevOps (Architekt)
# Trigger: EXCLUSIVELY manual (workflow_dispatch).
#
# This workflow reverts the deploy path to the code at a
# given git tag/ref, then rebuilds and redeploys the stack.
#
# Strategy: git checkout <tag> → docker compose up -d --build
# This is a "full restart rollback" — safest for containerized
# apps where DB schema changes may need the matching API binary.
#
# DB migrations: the API runs MigrateAsync on startup. If the
# rollback-tag's migration history is a prefix of the current DB,
# EF Core handles this gracefully (no-op for already-applied
# migrations). If the tag predates a destructive migration, manual
# DB intervention is needed — that's an edge case surfaced to DevOps.
# ───────────────────────────────────────────────────────
concurrency:
group: deploy-production
cancel-in-progress: false
on:
workflow_dispatch:
inputs:
target_tag:
description: 'Git tag to roll back to (e.g. v0.2.49)'
required: true
type: string
confirm:
description: 'Type "ROLLBACK" to confirm'
required: true
type: string
jobs:
rollback:
name: Rollback Nexus
runs-on: ubuntu-latest
env:
DEPLOY_PATH: /opt/openclaw/data/openclaw/workspace/nexus
ENV_TMPFILE: /tmp/nexus-rollback-env
ENV_POSTGRES_PASSWORD: ${{ secrets.ENV_POSTGRES_PASSWORD }}
ENV_JWT_KEY: ${{ secrets.ENV_JWT_KEY }}
ENV_OWNER_PASSWORD: ${{ secrets.ENV_OWNER_PASSWORD }}
ENV_OPENCLAW_TOKEN: ${{ secrets.ENV_OPENCLAW_TOKEN }}
steps:
# ═══════════════════════════════════════════════════
# Step 0: Safety gate — require explicit confirmation
# ═══════════════════════════════════════════════════
- name: Safety Gate
run: |
if [ "${{ inputs.confirm }}" != "ROLLBACK" ]; then
echo "❌ Rollback aborted: confirmation string must be 'ROLLBACK'"
echo " You entered: '${{ inputs.confirm }}'"
exit 1
fi
echo "✅ Rollback confirmed — proceeding to ${{ inputs.target_tag }}"
# ═══════════════════════════════════════════════════
# Step 1: Checkout target tag
# ═══════════════════════════════════════════════════
- name: Checkout target tag
uses: actions/checkout@v4
with:
ref: refs/tags/${{ inputs.target_tag }}
fetch-depth: 0
fetch-tags: true
# ═══════════════════════════════════════════════════
# Step 2: Verify tag exists
# ═══════════════════════════════════════════════════
- name: Verify tag
run: |
set -euo pipefail
ACTUAL_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "")
if [ -z "$ACTUAL_TAG" ]; then
echo "❌ Tag '${{ inputs.target_tag }}' not found in repository"
echo " Available tags:"
git tag -l 'v*' | sort -V | tail -20
exit 1
fi
echo "✅ Checked out: $ACTUAL_TAG"
echo " Commit: $(git rev-parse --short HEAD)"
echo " Message: $(git log -1 --oneline)"
# Read version from VERSION file at this tag
if [ -f VERSION ]; then
VERSION=$(cat VERSION | tr -d '[:space:]')
echo " VERSION: $VERSION"
fi
# ═══════════════════════════════════════════════════
# Step 3: Prepare .env from secrets (safe temp file)
# ═══════════════════════════════════════════════════
- name: Prepare .env (secrets → temp file)
run: |
set -euo pipefail
cat > "${ENV_TMPFILE}" <<EOF
# Nexus Production Environment — auto-generated by CD pipeline
POSTGRES_DB=nexus
POSTGRES_USER=nexus
POSTGRES_PASSWORD=${ENV_POSTGRES_PASSWORD}
JWT_KEY=${ENV_JWT_KEY}
JWT_ISSUER=nexus
JWT_AUDIENCE=nexus-web
OWNER_EMAIL=vmbao62@hotmail.de
OWNER_PASSWORD=${ENV_OWNER_PASSWORD}
OWNER_DISPLAY_NAME=
OPENCLAW_BASE_URL=http://host.docker.internal:18789
OPENCLAW_GATEWAY_TOKEN=${ENV_OPENCLAW_TOKEN}
OPENCLAW_GATEWAY_PASSWORD=
EOF
chmod 600 "${ENV_TMPFILE}"
echo "✅ .env written to ${ENV_TMPFILE} (mode 600)"
# ═══════════════════════════════════════════════════
# Step 4: Sync rollback code to host
# ═══════════════════════════════════════════════════
- name: Sync code to host
run: |
set -euo pipefail
docker run --rm \
-v "${{ gitea.workspace }}:/src:ro" \
-v "${DEPLOY_PATH}:/dest" \
alpine:latest \
sh -c "
cd /src && \
find . -mindepth 1 -maxdepth 1 \
! -name .git \
-exec cp -r {} /dest/ \; && \
DEST_OWNER=\$(stat -c '%u:%g' /dest) && \
chown -R \"\$DEST_OWNER\" /dest
"
echo "✅ Rollback code (${{ inputs.target_tag }}) synced to ${DEPLOY_PATH}"
# ═══════════════════════════════════════════════════
# Step 5: Rebuild & Redeploy
# ═══════════════════════════════════════════════════
- name: Rebuild & Redeploy
run: |
set -euo pipefail
docker run --rm \
-v "${DEPLOY_PATH}:/workspace/nexus" \
-v "/tmp:/tmp-host:ro" \
-v /var/run/docker.sock:/var/run/docker.sock \
-w /workspace/nexus \
docker:cli \
sh -c "
set -e
echo '🔙 Rolling back to ${{ inputs.target_tag }}'
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") build --no-cache
docker compose --env-file /tmp-host/$(basename "${ENV_TMPFILE}") up -d --wait --force-recreate
"
echo "✅ Rollback redeploy completed"
# ═══════════════════════════════════════════════════
# Step 6: Clean up temp .env
# ═══════════════════════════════════════════════════
- name: Clean up temp .env
if: always()
run: |
if [ -f "${ENV_TMPFILE}" ]; then
shred -u "${ENV_TMPFILE}" 2>/dev/null || rm -f "${ENV_TMPFILE}"
echo "🧹 Temp .env removed"
fi
# ═══════════════════════════════════════════════════
# Step 7: Health Check
# ═══════════════════════════════════════════════════
- name: Health Check
run: |
echo "🏥 Health check after rollback..."
RETRY=0
MAX=6
WAIT=1
while [ $RETRY -lt $MAX ]; do
RETRY=$((RETRY + 1))
if curl -sf --max-time 10 https://nexus.noveria.net/health; then
echo ""
echo "✅ Health check passed (attempt $RETRY/$MAX)"
exit 0
fi
echo "⏳ Attempt $RETRY/$MAX failed, waiting ${WAIT}s..."
sleep $WAIT
NEXT=$((WAIT + RETRY))
[ $NEXT -le 15 ] && WAIT=$NEXT || WAIT=15
done
echo "❌ Health check failed after $MAX attempts"
exit 1
# ═══════════════════════════════════════════════════
# Step 8: Smoke Test
# ═══════════════════════════════════════════════════
- name: Smoke Test
run: |
echo "🔍 Smoke test after rollback..."
PASS=0
FAIL=0
BASE="https://nexus.noveria.net"
check() {
local path="$1" label="$2" expected="${3:-200}"
local code
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${BASE}${path}")
printf " %-25s HTTP %s" "${label}:" "${code}"
if [ "$code" = "$expected" ]; then
echo " ✅"
PASS=$((PASS + 1))
else
echo " ❌ (expected $expected)"
FAIL=$((FAIL + 1))
fi
}
check "/dashboard" "Dashboard" 200
check "/health" "Health API" 200
check "/api/v1/operations/snapshot" "Operations API (auth)" 401
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -gt 0 ]; then
echo "❌ Smoke test failed!"
exit 1
fi
echo "✅ Rollback to ${{ inputs.target_tag }} successful"
# ═══════════════════════════════════════════════════
# Step 9: Rollback Summary
# ═══════════════════════════════════════════════════
- name: Rollback Summary
if: always()
run: |
echo ""
echo "═══════════════════════════════════════"
echo " 🔙 Rollback Summary"
echo "═══════════════════════════════════════"
echo " Rolled to: ${{ inputs.target_tag }}"
echo " Triggered: @${{ gitea.actor }}"
echo " Status: ${{ job.status }}"
echo "═══════════════════════════════════════"
# ═══════════════════════════════════════════════════
# Step 10: Failure → Reviewer Handoff
# ═══════════════════════════════════════════════════
- name: 🔴 Rollback Failed — Reviewer Handoff
if: failure()
run: |
echo ""
echo "┌─────────────────────────────────────────────────────────────┐"
echo "│ 🔴 ROLLBACK FAILED — Reviewer muss fixen │"
echo "├─────────────────────────────────────────────────────────────┤"
echo "│ │"
echo "│ Target: ${{ inputs.target_tag }}"
echo "│ Job: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}"
echo "│ │"
echo "│ → DevOps (Architekt) analysiert den Fehler │"
echo "│ → Reviewer (Code-Fixer) behebt das Problem │"
echo "│ → DevOps verifiziert mit neuem Deploy │"
echo "│ │"
echo "│ Letzter bekannter funktionierender Stand: │"
echo "│ → 'git log --oneline -5' zeigt letzte Commits │"
echo "│ → Manuellen Rollback erwägen: │"
echo "│ cd /opt/openclaw/data/openclaw/workspace/nexus │"
echo "│ docker compose up -d (vorheriger Stand) │"
echo "│ │"
echo "└─────────────────────────────────────────────────────────────┘"
+4 -2
View File
@@ -30,5 +30,7 @@ docker-compose.override.yml
*.tmp *.tmp
*.bak *.bak
# pnpm # pnpm (lockfile IS committed for reproducible CI builds)
pnpm-lock.yaml
# Claude local config (per-developer, not repo-shared)
.claude/
+81 -19
View File
@@ -3,7 +3,11 @@
Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an Nexus is the operations platform for the Noveria ecosystem. OpenClaw is an
adapter-backed agent runtime, not a dependency of the frontend or domain model. adapter-backed agent runtime, not a dependency of the frontend or domain model.
> CI/CD auto-deploy enabled — every push to main triggers build → test → deploy. > CI runs automatically on every push. CD can run **automatically after successful CI**
> on main (patch-bump default) or can be triggered **manually** (workflow_dispatch) with
> full parameter control. Main deploys bump/tag a release; arbitrary `git_ref` deploys
> stay read-only. Rollback and database backup are separate manual workflows.
> See [phases/deployment.md](phases/deployment.md) for full CD documentation.
## Current foundation ## Current foundation
@@ -11,10 +15,9 @@ adapter-backed agent runtime, not a dependency of the frontend or domain model.
- ASP.NET Core 10 REST API (Minimal API pattern) - ASP.NET Core 10 REST API (Minimal API pattern)
- Entity Framework Core and PostgreSQL - Entity Framework Core and PostgreSQL
- JWT owner authentication with rotating refresh sessions - JWT owner authentication with rotating refresh sessions
- `IAgentRuntime` abstraction with an OpenClaw adapter - `IAgentRuntime` abstraction with an OpenClaw adapter (Ollama and NVIDIA removed — OpenClaw-only)
- `IModelProvider` abstractions for Ollama and NVIDIA
- Responsive dark-mode operations dashboard - Responsive dark-mode operations dashboard
- Container-only entry point on `127.0.0.1:18880` - Traefik reverse-proxy with Let's Encrypt TLS on `nexus.noveria.net`
## Local/container start ## Local/container start
@@ -27,12 +30,11 @@ curl http://127.0.0.1:18880/health
``` ```
On an empty database the API creates exactly one owner from `OWNER_EMAIL`, On an empty database the API creates exactly one owner from `OWNER_EMAIL`,
`OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 14 `OWNER_PASSWORD` and `OWNER_DISPLAY_NAME`. The password must contain at least 10
characters. Existing databases are never overwritten by the bootstrap process. characters. Existing databases are never overwritten by the bootstrap process.
The web service is loopback-only. Public reverse-proxy activation for The API is exposed via Traefik reverse-proxy with automatic Let's Encrypt TLS.
`nexus.noveria.net` remains a separate infrastructure change and must terminate Health checks, rate limiting, and security headers are active.
TLS before forwarding to port `18880`.
## Workspace mounts ## Workspace mounts
@@ -41,12 +43,12 @@ and the config editor. These are mounted under `/mnt/workspace-{agentId}`:
| Host path | Container mount | | Host path | Container mount |
|---|---| |---|---|
| `/opt/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-iris` | `/mnt/workspace-iris` |
| `/opt/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-programmer` | `/mnt/workspace-programmer` |
| `/opt/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-reviewer` | `/mnt/workspace-reviewer` |
| `/opt/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-architekt` | `/mnt/workspace-architekt` |
| `/opt/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-researcher` | `/mnt/workspace-researcher` |
| `/opt/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` | | `/home/projekte_bao/openclaw/data/openclaw/workspace-executor` | `/mnt/workspace-executor` |
## Frontend architecture ## Frontend architecture
@@ -279,12 +281,72 @@ Backlog → Blocked → In progress / Done
provider key. Conversation IDs are stable per browser and Iris is the default provider key. Conversation IDs are stable per browser and Iris is the default
agent target. agent target.
The configured model-routing policy is: The configured model-routing policy routes through the OpenClaw Gateway only.
Ollama and NVIDIA providers have been removed. Currently active models:
1. `qwen3:4b` through Ollama for routine and monitoring work | Agent | Model |
2. `moonshotai/kimi-k2.6` through NVIDIA for primary work |-------|-------|
3. `gpt-5.5` through OpenClaw for strategic and critical review | Iris | `openai/gpt-5.4` |
| Programmer, Executor | `deepseek/deepseek-v4-flash` |
| Reviewer, Architekt, Researcher | `deepseek/deepseek-v4-pro` |
Claude models (Sonnet 4.6, Opus 4.6/4.7/4.8) are available via `claude-cli` backend.
The Settings module reports runtime and provider state without exposing The Settings module reports runtime and provider state without exposing
credentials. credentials.
# Trigger CI
## CI/CD
### CI — Automatic
Every push to `main` triggers `.gitea/workflows/ci.yaml`:
- **Backend**: .NET restore → build → test
- **Frontend**: pnpm install → type-check → test → build
- **Security**: Scan for hardcoded secrets in source code
CI must never break. If it does, Reviewer fixes.
### CD — Auto + Manual (CD v3)
Deployment can happen automatically or manually:
#### Auto-Deploy (after successful CI on main)
- Triggered by `workflow_run` after `CI - Build & Test` succeeds on `main`
- Uses safe defaults: `patch` bump, all services, main ref
- Skips automatically if the triggering commit contains `[skip ci]` (version-bump commits)
- The version-bump commit itself uses `[skip ci]` → no infinite CI→Deploy→Bump→CI loops
#### Manual Deploy (`workflow_dispatch`)
1. DevOps triggers `Deploy to Production` in Gitea Actions (or Iris auto-approves)
2. Chooses version bump type: patch (default) / minor / major
3. Optionally scopes to a single service or specific git ref
4. Workflow bumps VERSION, creates git tag, builds and deploys
5. Health check + smoke test verify the deployment
#### Rollback (`workflow_dispatch`)
1. DevOps triggers `Rollback to Previous Version` in Gitea Actions
2. Enters target git tag (e.g. `v0.2.49`) + confirmation `ROLLBACK`
3. Workflow checks out the tag, rebuilds with `--no-cache`, redeploys
4. Health check + smoke test verify the rollback
#### Database Backup (`workflow_dispatch`)
1. DevOps triggers `Database Backup` in Gitea Actions
2. Optionally also copies backup to a host path (`/home/projekte_bao/backups`)
3. Workflow dumps PostgreSQL via `pg_dumpall`, gzips, and uploads as a Gitea artifact
4. Artifacts are retained for 90 days (configurable)
5. Optional nightly schedule (uncomment the cron trigger in `backup.yaml`)
#### Failure Handling
When deploy or rollback fails:
- **DevOps (Architekt)** analyses the error
- **Reviewer (Code-Fixer)** fixes the problem
- **DevOps** re-deploys to verify the fix
The workflow outputs a formatted handoff message with the job URL.
Full CD documentation: [phases/deployment.md](phases/deployment.md)
+1 -1
View File
@@ -1 +1 @@
0.2.6 0.2.56
+52 -18
View File
@@ -11,12 +11,8 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentsAsync_ReturnsCorrectCount() public async Task GetAgentsAsync_ReturnsCorrectCount()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
@@ -27,12 +23,8 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentAsync_Iris_ReturnsOrchestrator() public async Task GetAgentAsync_Iris_ReturnsOrchestrator()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
@@ -44,18 +36,60 @@ public class AgentServiceTests
[Fact] [Fact]
public async Task GetAgentAsync_Unknown_ReturnsNull() public async Task GetAgentAsync_Unknown_ReturnsNull()
{ {
var config = new ConfigurationBuilder() var configPath = CreateAgentConfigFile();
.AddInMemoryCollection(new Dictionary<string, string?> var config = CreateConfiguration(configPath);
{
["AgentConfigPath"] = "/home/node/.openclaw/openclaw.json"
})
.Build();
var runtime = new FakeRuntime(); var runtime = new FakeRuntime();
var service = new AgentService(config, runtime); var service = new AgentService(config, runtime);
var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None); var agent = await service.GetAgentAsync("nonexistent", CancellationToken.None);
Assert.Null(agent); Assert.Null(agent);
} }
private static IConfiguration CreateConfiguration(string configPath)
=> new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["AgentConfigPath"] = configPath
})
.Build();
private static string CreateAgentConfigFile()
{
var path = Path.Combine(Path.GetTempPath(), $"agent-config-{Guid.NewGuid():N}.json");
File.WriteAllText(path,
"""
{
"agents": {
"defaults": {
"workspace": "/workspace/default",
"model": {
"primary": "deepseek/deepseek-v4-flash"
}
},
"list": [
{
"id": "iris",
"name": "iris"
},
{
"id": "programmer",
"name": "programmer"
},
{
"id": "reviewer",
"name": "reviewer"
},
{
"id": "architekt",
"name": "architekt"
}
]
}
}
""");
return path;
}
} }
public sealed class FakeRuntime : IAgentRuntime public sealed class FakeRuntime : IAgentRuntime
+143
View File
@@ -0,0 +1,143 @@
using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Nexus.Api.Controllers;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services;
using Xunit;
namespace Nexus.Api.Tests;
public class OperationsSnapshotTests
{
[Fact]
public void GetSnapshot_RequiresAuthorization()
{
var method = typeof(OperationsController).GetMethod(nameof(OperationsController.GetSnapshot), BindingFlags.Instance | BindingFlags.Public);
Assert.NotNull(method);
Assert.NotNull(method!.GetCustomAttribute<AuthorizeAttribute>());
}
[Fact]
public async Task GetSnapshotAsync_DoesNotOverlapRepositoryReads()
{
var guard = new RepositoryConcurrencyGuard();
var runtime = new SnapshotRuntimeStub();
var agentService = new SnapshotAgentServiceStub();
var projectRepo = new GuardedProjectRepository(guard);
var taskRepo = new GuardedTaskRepository(guard);
var activityRepo = new GuardedActivityRepository(guard);
var service = new OperationsService(runtime, agentService, projectRepo, taskRepo, activityRepo);
await service.GetSnapshotAsync(CancellationToken.None);
Assert.Equal(1, guard.MaxConcurrentCalls);
}
}
internal sealed class RepositoryConcurrencyGuard
{
private readonly Lock sync = new();
private int currentCalls;
public int MaxConcurrentCalls { get; private set; }
public async Task<T> RunAsync<T>(T value, CancellationToken ct)
{
lock (sync)
{
currentCalls++;
MaxConcurrentCalls = Math.Max(MaxConcurrentCalls, currentCalls);
}
try
{
await Task.Delay(25, ct);
return value;
}
finally
{
lock (sync)
{
currentCalls--;
}
}
}
}
internal sealed class GuardedProjectRepository(RepositoryConcurrencyGuard guard) : IProjectRepository
{
public Task<List<Project>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<Project>
{
new() { Name = "Alpha", Status = OperationalStatus.Online, Progress = 75 }
}, ct);
public ValueTask<Project?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<Project> AddAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(Project project, CancellationToken ct = default) => throw new NotSupportedException();
public Task<bool> HasTasksAsync(Guid projectId, CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedTaskRepository(RepositoryConcurrencyGuard guard) : ITaskRepository
{
public Task<List<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> guard.RunAsync(new List<WorkTask>
{
new() { Title = "Blocked task", State = TaskStateHelper.ToStateString(TaskState.Blocked), UpdatedAt = DateTimeOffset.UtcNow },
new() { Title = "Done task", State = TaskStateHelper.ToStateString(TaskState.Done), UpdatedAt = DateTimeOffset.UtcNow }
}, ct);
public ValueTask<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default) => throw new NotSupportedException();
public Task<List<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask> AddAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task UpdateAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task DeleteAsync(WorkTask task, CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountAsync(CancellationToken ct = default) => throw new NotSupportedException();
public Task<int> CountByStateAsync(string state, CancellationToken ct = default) => throw new NotSupportedException();
public Task<WorkTask?> GetLastBlockedAsync(CancellationToken ct = default) => throw new NotSupportedException();
}
internal sealed class GuardedActivityRepository(RepositoryConcurrencyGuard guard) : IActivityRepository
{
public Task<List<ActivityEvent>> GetRecentAsync(int take, CancellationToken ct = default)
=> guard.RunAsync(new List<ActivityEvent>
{
new() { Id = 1, Type = "agent", Message = "recent activity", CreatedAt = DateTimeOffset.UtcNow }
}, ct);
public Task<(List<ActivityEvent> Items, int TotalCount)> GetPagedAsync(string? type, string? sort, int page, int pageSize, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<List<ActivityEvent>> GetByAgentAsync(string agentId, int take, CancellationToken ct = default)
=> throw new NotSupportedException();
public Task<ActivityEvent> AddAsync(ActivityEvent activity, CancellationToken ct = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotRuntimeStub : IAgentRuntime
{
public string Name => "stub";
public Task<AgentRuntimeStatus> GetStatusAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(new AgentRuntimeStatus("OpenClaw", OperationalStatus.Online, TimeSpan.FromMilliseconds(5), "ok"));
public Task<AgentChatResult> ChatAsync(string message, string conversationId, string agentId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
internal sealed class SnapshotAgentServiceStub : IAgentService
{
public Task<IReadOnlyCollection<AgentInfo>> GetAgentsAsync(CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyCollection<AgentInfo>>(
[
new AgentInfo("iris", "Iris", "Orchestrator", "model", OperationalStatus.Online, DateTimeOffset.UtcNow, "/workspace", "ops")
]);
public Task<AgentDetail?> GetAgentAsync(string id, CancellationToken cancellationToken)
=> throw new NotSupportedException();
}
+28 -60
View File
@@ -1,8 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Helpers;
using Nexus.Api.Integrations; using Nexus.Api.Integrations;
using Nexus.Api.Repositories; using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
@@ -15,6 +13,7 @@ public class AgentsController(
IAgentService agentService, IAgentService agentService,
IAgentRuntime runtime, IAgentRuntime runtime,
IActivityRepository activityRepo, IActivityRepository activityRepo,
IAgentConfigService agentConfigService,
ILogger<AgentsController> logger) : ControllerBase ILogger<AgentsController> logger) : ControllerBase
{ {
[HttpGet] [HttpGet]
@@ -22,8 +21,7 @@ public class AgentsController(
{ {
var agents = await agentService.GetAgentsAsync(ct); var agents = await agentService.GetAgentsAsync(ct);
return Results.Ok(agents.Select(a => new AgentListResponse( return Results.Ok(agents.Select(a => new AgentListResponse(
a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description a.Id, a.Name, a.Role, a.Model, a.Status.ToString(), a.LastSeen, a.Workspace, a.Description)));
)));
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -34,8 +32,7 @@ public class AgentsController(
return Results.Ok(new AgentDetailResponse( return Results.Ok(new AgentDetailResponse(
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(), agent.Id, agent.Name, agent.Role, agent.Model, agent.Status.ToString(),
agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description, agent.LastSeen, agent.Workspace, agent.AgentDir, agent.Description,
agent.SubAgents, agent.IdentityName agent.SubAgents, agent.IdentityName));
));
} }
[HttpGet("{id}/activity")] [HttpGet("{id}/activity")]
@@ -58,9 +55,7 @@ public class AgentsController(
try try
{ {
var result = await runtime.ChatAsync(message, conversationId, id, ct); var result = await runtime.ChatAsync(message, conversationId, id, ct);
await activityRepo.AddAsync(new Data.ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "agent", Message = $"Command sent to agent {id}: {message[..Math.Min(message.Length, 80)]}" }, ct);
return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content)); return Results.Ok(new AgentCommandResponse(result.Runtime, result.AgentId, result.ConversationId, result.Content));
} }
catch (Exception exception) catch (Exception exception)
@@ -73,79 +68,52 @@ public class AgentsController(
} }
} }
// ========== Agent Config Editor ========== // ── Config Editor ──
[HttpGet("{id}/config")] [HttpGet("{id}/config")]
public IResult GetConfig(string id) public IResult GetConfig(string id)
{ => Results.Ok(agentConfigService.GetConfigFiles(id));
var workspacePath = $"/mnt/workspace-{id}";
if (!Directory.Exists(workspacePath))
return Results.Ok(Array.Empty<object>());
var allowedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
};
var files = Directory.GetFiles(workspacePath, "*.md")
.Select(f => new FileInfo(f))
.Where(f => allowedFiles.Contains(f.Name))
.OrderBy(f => f.Name)
.Select(f => new
{
fileName = f.Name,
size = f.Length,
modifiedAt = f.LastWriteTimeUtc
})
.ToList();
return Results.Ok(files);
}
[HttpGet("{id}/config/{fileName}")] [HttpGet("{id}/config/{fileName}")]
public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct) public async Task<IResult> GetConfigFile(string id, string fileName, CancellationToken ct)
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName)) var file = await agentConfigService.GetConfigFileAsync(id, fileName, ct);
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." }); return file is null
? Results.NotFound()
var workspacePath = $"/mnt/workspace-{id}"; : Results.Ok(new { file.FileName, file.Content, file.Size, file.ModifiedAt });
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !System.IO.File.Exists(safePath))
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(safePath!, ct);
var fi = new FileInfo(safePath!);
return Results.Ok(new { fileName, content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
} }
[HttpPut("{id}/config/{fileName}")] [HttpPut("{id}/config/{fileName}")]
public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct) public async Task<IResult> SaveConfigFile(string id, string fileName, [FromBody] SaveConfigRequest request, CancellationToken ct)
{ {
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return Results.BadRequest(new { error = "Invalid filename. Only .md files with alphanumeric characters, dots, hyphens, and underscores are allowed." });
if (request.Content is null) if (request.Content is null)
return Results.BadRequest(new { error = "Content is required." }); return Results.BadRequest(new { error = "Content is required." });
if (request.Content.Length > 500 * 1024) if (request.Content.Length > 500 * 1024)
return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." }); return Results.BadRequest(new { error = "Content exceeds maximum size of 500KB." });
var workspacePath = $"/mnt/workspace-{id}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return Results.NotFound();
var tempPath = safePath + ".tmp";
try try
{ {
await System.IO.File.WriteAllTextAsync(tempPath, request.Content, ct); var result = await agentConfigService.SaveConfigFileAsync(id, fileName, request.Content, ct);
System.IO.File.Move(tempPath, safePath, overwrite: true); return result is null
? Results.BadRequest(new { error = "Invalid filename or path." })
: Results.Ok(new { result.FileName, result.Size, result.ModifiedAt });
} }
catch catch (UnauthorizedAccessException ex)
{ {
if (System.IO.File.Exists(tempPath)) System.IO.File.Delete(tempPath); logger.LogError(ex, "Permission denied saving config file {FileName} for agent {AgentId}", fileName, id);
throw; return Results.Problem(
title: "Permission denied",
detail: $"Cannot write config file '{fileName}' for agent '{id}'. The target path may be owned by a different user.",
statusCode: StatusCodes.Status500InternalServerError);
}
catch (IOException ex)
{
logger.LogError(ex, "I/O error saving config file {FileName} for agent {AgentId}", fileName, id);
return Results.Problem(
title: "File write error",
detail: $"Failed to write config file '{fileName}' for agent '{id}': {ex.Message}",
statusCode: StatusCodes.Status500InternalServerError);
} }
var fi = new FileInfo(safePath);
return Results.Ok(new { fileName, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
} }
} }
+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 }); : 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")] [HttpPost("change-password")]
public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct) public async Task<IResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
{ {
+4 -67
View File
@@ -1,80 +1,17 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.DTOs; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/calendar")] [Route("api/v1/calendar")]
public class CalendarController(IConfiguration config, IHttpClientFactory httpClientFactory, ILogger<CalendarController> logger) : ControllerBase public class CalendarController(ICalendarService calendarService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
{ => Results.Ok(await calendarService.GetCronJobsAsync(ct));
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
try
{
var httpClient = httpClientFactory.CreateClient("gateway");
if (!string.IsNullOrWhiteSpace(gatewayToken))
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
var response = await httpClient.GetAsync("/api/cron", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
return Results.Ok(data ?? new List<CronJobEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data.");
}
var fallbackJobs = new List<object>
{
new { id = "health-check", name = "Health Check", schedule = "*/5 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-3).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(2).ToString("O"), status = "completed" },
new { id = "memory-sync", name = "Memory Sync", schedule = "0 */6 * * *", lastRun = DateTimeOffset.UtcNow.AddHours(-2).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddHours(4).ToString("O"), status = "completed" },
new { id = "task-cleanup", name = "Task Cleanup", schedule = "0 3 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(3).ToString("O"), status = "completed" },
new { id = "backup", name = "Database Backup", schedule = "0 4 * * *", lastRun = DateTimeOffset.UtcNow.AddDays(-1).AddHours(-1).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddDays(1).AddHours(4).ToString("O"), status = "completed" },
new { id = "model-routing-refresh", name = "Model Routing Refresh", schedule = "*/30 * * * *", lastRun = DateTimeOffset.UtcNow.AddMinutes(-12).ToString("O"), nextRun = DateTimeOffset.UtcNow.AddMinutes(18).ToString("O"), status = "running" },
};
return Results.Ok(fallbackJobs);
}
[HttpGet("upcoming")] [HttpGet("upcoming")]
public async Task<IResult> GetUpcoming(CancellationToken ct) public async Task<IResult> GetUpcoming(CancellationToken ct)
{ => Results.Ok(await calendarService.GetUpcomingCronJobsAsync(ct));
var gatewayToken = config["Integrations:OpenClaw:Token"] ?? "";
try
{
var httpClient = httpClientFactory.CreateClient("gateway");
if (!string.IsNullOrWhiteSpace(gatewayToken))
httpClient.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", gatewayToken);
var response = await httpClient.GetAsync("/api/cron/upcoming", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
return Results.Ok(data ?? new List<UpcomingCronEntry>());
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data.");
}
var now = DateTimeOffset.UtcNow;
var fallback = new List<object>
{
new { id = "health-check", name = "Health Check", nextRun = now.AddMinutes(2).ToString("O"), schedule = "*/5 * * * *" },
new { id = "model-routing-refresh", name = "Model Routing Refresh", nextRun = now.AddMinutes(18).ToString("O"), schedule = "*/30 * * * *" },
new { id = "memory-sync", name = "Memory Sync", nextRun = now.AddHours(4).ToString("O"), schedule = "0 */6 * * *" },
new { id = "task-cleanup", name = "Task Cleanup", nextRun = now.AddDays(1).AddHours(3).ToString("O"), schedule = "0 3 * * *" },
new { id = "backup", name = "Database Backup", nextRun = now.AddDays(1).AddHours(4).ToString("O"), schedule = "0 4 * * *" },
};
return Results.Ok(fallback);
}
} }
+163
View File
@@ -0,0 +1,163 @@
using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Models;
using Nexus.Api.Services;
namespace Nexus.Api.Controllers;
[ApiController]
[Route("api/dashboard")]
public class DashboardController(IDashboardService dashboardService, ITaskService taskService) : ControllerBase
{
[HttpGet("status")]
public async Task<DashboardStatus> GetStatus()
=> await dashboardService.GetStatusAsync();
[HttpGet("agents")]
public async Task<List<DashboardAgentInfo>> GetAgents()
=> await dashboardService.GetAgentsAsync();
[HttpGet("operations")]
public async Task<List<FeedEntry>> GetOperations(
[FromQuery] int limit = 20,
[FromQuery] string? agent = null)
=> await dashboardService.GetOperationsAsync(limit, agent);
[HttpPost("chat/send")]
public async Task<ChatResponse> SendChat([FromBody] ChatRequest request)
{
if (string.IsNullOrWhiteSpace(request.Message))
return new ChatResponse(false, null, "Message is required");
var agentId = string.IsNullOrWhiteSpace(request.AgentId) ? "iris" : request.AgentId.Trim();
return await dashboardService.SendChatAsync(agentId, request.Message.Trim());
}
[HttpGet("chat/messages")]
public async Task<List<MessageEntry>> GetMessages(
[FromQuery] string? sessionKey,
[FromQuery] int limit = 50,
[FromQuery] int offset = 0)
=> await dashboardService.GetMessagesAsync(sessionKey, limit, offset);
[HttpGet("queue")]
public async Task<List<QueueItem>> GetQueue(CancellationToken ct)
=> await dashboardService.GetQueueAsync(ct);
[HttpDelete("queue/{id}")]
public async Task<ActionResult> DeleteQueueItem(string id, [FromQuery] string? source, CancellationToken ct)
{
var result = await dashboardService.DeleteQueueItemAsync(id, source, ct);
return result.Outcome switch
{
QueueDeleteOutcome.Deleted => NoContent(),
QueueDeleteOutcome.NotFound => NotFound(new { error = "Queue item not found" }),
QueueDeleteOutcome.GatewayError => StatusCode(502, new { error = "Gateway could not delete cron job" }),
QueueDeleteOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueueDeleteOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
_ => StatusCode(500, new { error = "Internal error" })
};
}
[HttpPut("queue/{id}/priority")]
public async Task<ActionResult> ChangeQueuePriority(string id, CancellationToken ct)
{
var result = await dashboardService.CycleQueuePriorityAsync(id, ct);
return result.Outcome switch
{
QueuePriorityOutcome.Ignored => Ok(new { status = "ignored", reason = "Cron job priorities are managed by the gateway" }),
QueuePriorityOutcome.TaskNotFound => NotFound(new { error = "Task not found" }),
QueuePriorityOutcome.InvalidTaskId => BadRequest(new { error = "Invalid task id" }),
_ => Ok(new { status = "ok", priority = result.NewPriority })
};
}
[HttpGet("agents/{id}/model")]
public async Task<ActionResult<AgentModelInfo>> GetAgentModel(string id)
{
var info = await dashboardService.GetAgentModelAsync(id);
return info is null
? NotFound(new { error = $"Agent '{id}' not found or gateway unreachable" })
: Ok(info);
}
[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" });
var ok = await dashboardService.SetAgentModelAsync(id, request.Model);
return ok ? Ok(new { status = "ok", model = request.Model }) : StatusCode(502, new { error = "Gateway did not accept the change" });
}
[HttpGet("agents/{id}/activity")]
public async Task<List<AgentActivityEntry>> GetAgentActivity(string id, [FromQuery] int limit = 5)
=> await dashboardService.GetAgentActivityAsync(id, limit);
[HttpGet("models")]
public ActionResult<List<ModelOption>> GetAvailableModels()
=> Ok(dashboardService.GetAvailableModels());
// ── Task Endpoints ──
[HttpGet("tasks")]
public async Task<List<DashboardTaskDto>> GetTasks(CancellationToken ct)
{
var tasks = await taskService.GetOpenAsync(ct);
return tasks.Select(MapToDto).ToList();
}
[HttpPost("tasks")]
public async Task<ActionResult<DashboardTaskDto>> CreateTask(
[FromBody] CreateDashboardTaskRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest(new { error = "Title is required." });
var task = await taskService.CreateDashboardTaskAsync(
request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
return Created($"/api/dashboard/tasks/{task.Id}", MapToDto(task));
}
[HttpPut("tasks/{id:guid}")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTask(
Guid id, [FromBody] UpdateDashboardTaskRequest request, CancellationToken ct)
{
var result = await taskService.UpdateDashboardTaskAsync(
id, request.Title, request.Detail, request.Source, request.Priority, request.AssignedTo, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
[HttpDelete("tasks/{id:guid}")]
public async Task<ActionResult> DeleteTask(Guid id, CancellationToken ct)
{
var result = await taskService.DeleteAsync(id, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
TaskOperationOutcome.InvalidState => StatusCode(403, new { error = "Only tasks in 'Done' or 'Backlog' state can be deleted." }),
_ => NoContent()
};
}
[HttpPatch("tasks/{id:guid}/status")]
public async Task<ActionResult<DashboardTaskDto>> UpdateTaskStatus(
Guid id, [FromBody] UpdateDashboardTaskStatusRequest request, CancellationToken ct)
{
var result = await taskService.UpdateStatusAsync(id, request.Status, ct);
return result.Outcome switch
{
TaskOperationOutcome.InvalidState => BadRequest(new { error = $"Unsupported status: '{request.Status}'. Valid: {string.Join(", ", TaskStateHelper.AllStates)}" }),
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
_ => Ok(MapToDto(result.Task!))
};
}
private static DashboardTaskDto MapToDto(WorkTask t) => new(
t.Id, t.Title, t.Detail, t.Source, t.State, t.Priority, t.AssignedTo, t.CreatedAt, t.UpdatedAt);
}
+5 -51
View File
@@ -1,47 +1,15 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/docs")] [Route("api/v1/docs")]
public class DocsController : ControllerBase public class DocsController(IDocService docService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public IResult GetAll() public IResult GetAll()
{ => Results.Ok(docService.GetAll());
var workspaceRoot = "/mnt/workspace-iris";
var results = new List<object>();
void ScanDir(string dir, string category)
{
if (!Directory.Exists(dir)) return;
foreach (var file in Directory.GetFiles(dir, "*.*"))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (ext is not (".md" or ".json" or ".txt" or ".yaml" or ".yml" or ".html" or ".css"))
continue;
var fi = new FileInfo(file);
results.Add(new
{
name = fi.Name,
path = file.Replace(workspaceRoot, "").TrimStart('/'),
category,
type = ext.Replace(".", ""),
size = fi.Length,
modifiedAt = fi.LastWriteTimeUtc
});
}
}
ScanDir("/mnt/workspace-iris/nexus-phases", "phases");
ScanDir("/mnt/workspace-iris/skills", "skills");
ScanDir("/mnt/workspace-iris", "workspace");
ScanDir("/home/node/.openclaw/workspace/nexus", "nexus");
ScanDir("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases");
return Results.Ok(results.OrderByDescending(x => ((DateTime)((dynamic)x).modifiedAt)).Take(100));
}
[HttpGet("{**path}")] [HttpGet("{**path}")]
public async Task<IResult> GetFile(string path) public async Task<IResult> GetFile(string path)
@@ -49,21 +17,7 @@ public class DocsController : ControllerBase
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
return Results.BadRequest("Path required."); return Results.BadRequest("Path required.");
string? resolvedPath = null; var file = await docService.GetFileAsync(path);
foreach (var root in new[] { "/mnt/workspace-iris", "/home/node/.openclaw/workspace/nexus" }) return file is null ? Results.NotFound() : Results.Ok(file);
{
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && System.IO.File.Exists(candidate))
{
resolvedPath = candidate;
break;
}
}
if (resolvedPath is null)
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(resolvedPath);
var fi = new FileInfo(resolvedPath);
return Results.Ok(new { name = fi.Name, path = resolvedPath.Replace("/mnt/workspace-iris/", "").Replace("/home/node/.openclaw/workspace/nexus/", ""), content, size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
} }
} }
+6
View File
@@ -7,6 +7,12 @@ namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase public class HealthController(IAgentRuntime runtime, HealthCheckService healthChecks) : ControllerBase
{ {
[HttpGet("/health/live")]
public IResult Live()
{
return Results.Ok(new { status = "Healthy", timestamp = DateTimeOffset.UtcNow });
}
[HttpGet("/health")] [HttpGet("/health")]
public async Task<IResult> Get(CancellationToken ct) public async Task<IResult> Get(CancellationToken ct)
{ {
+5 -85
View File
@@ -1,100 +1,20 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
using System.Text.RegularExpressions;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/incidents")] [Route("api/v1/incidents")]
public class IncidentsController : ControllerBase public class IncidentsController(IIncidentService incidentService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll() public async Task<IResult> GetAll()
{ => Results.Ok(await incidentService.GetAllAsync());
var basePath = "/mnt/workspace-iris/memory/incidents";
if (!Directory.Exists(basePath))
return Results.Ok(Array.Empty<object>());
var incidents = new List<object>();
foreach (var file in Directory.GetFiles(basePath, "*.md").OrderByDescending(f => f).Take(50))
{
var fi = new FileInfo(file);
if (fi.Length > 1_000_000) continue;
var name = Path.GetFileNameWithoutExtension(file);
var content = await System.IO.File.ReadAllTextAsync(file);
var title = name;
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
if (titleMatch.Success)
title = titleMatch.Groups[1].Value.Trim();
var date = (string?)null;
var dateMatch = Regex.Match(name, @"^(\d{4}-\d{2}-\d{2})");
if (dateMatch.Success)
date = dateMatch.Groups[1].Value;
var severity = "unknown";
var severityMatch = Regex.Match(content, @"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline);
if (severityMatch.Success)
severity = severityMatch.Groups[1].Value.Trim();
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
var excerpt = excerptEnd > 0
? content[..excerptEnd].Trim()
: content[..Math.Min(300, content.Length)].Trim();
if (excerpt.Length > 200)
excerpt = excerpt[..200] + "\u2026";
incidents.Add(new
{
name = Path.GetFileName(file),
title,
date,
severity,
excerpt,
size = fi.Length
});
}
return Results.Ok(incidents);
}
[HttpGet("{name}")] [HttpGet("{name}")]
public async Task<IResult> GetOne(string name) public async Task<IResult> GetOne(string name)
{ {
var basePath = "/mnt/workspace-iris/memory/incidents"; var incident = await incidentService.GetByNameAsync(name);
if (!PathSecurityHelper.TryResolveSafePath(basePath, name, out var filePath)) return incident is null ? Results.NotFound() : Results.Ok(incident);
return Results.BadRequest("Invalid filename.");
if (!System.IO.File.Exists(filePath!))
{
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
filePath = Path.Combine(basePath, name + ".md");
if (!System.IO.File.Exists(filePath!))
return Results.NotFound();
}
var content = await System.IO.File.ReadAllTextAsync(filePath!);
var fi = new FileInfo(filePath!);
var fileName = Path.GetFileName(filePath!);
var title = fileName;
var titleMatch = Regex.Match(content, @"^#\s+(.+)$", RegexOptions.Multiline);
if (titleMatch.Success)
title = titleMatch.Groups[1].Value.Trim();
var date = (string?)null;
var dateMatch = Regex.Match(fileName, @"^(\d{4}-\d{2}-\d{2})");
if (dateMatch.Success)
date = dateMatch.Groups[1].Value;
return Results.Ok(new
{
name = fileName,
title,
date,
content,
size = fi.Length
});
} }
} }
+7 -86
View File
@@ -1,40 +1,15 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Helpers; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/memory")] [Route("api/v1/memory")]
public class MemoryController : ControllerBase public class MemoryController(IMemoryService memoryService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public IResult GetAll() public async Task<IResult> GetAll()
{ => Results.Ok(await memoryService.GetAllAsync());
var basePath = "/mnt/workspace-iris/memory";
if (!Directory.Exists(basePath))
return Results.Ok(Array.Empty<object>());
var files = Directory.GetFiles(basePath, "*.md")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.Name)
.Select(f => new
{
name = f.Name,
path = f.FullName.Replace(basePath, "").TrimStart('/'),
size = f.Length,
modifiedAt = f.LastWriteTimeUtc
})
.ToList();
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (System.IO.File.Exists(longTermPath))
{
var fi = new FileInfo(longTermPath);
files.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", size = fi.Length, modifiedAt = fi.LastWriteTimeUtc });
}
return Results.Ok(files);
}
[HttpGet("search")] [HttpGet("search")]
public async Task<IResult> Search([FromQuery] string q) public async Task<IResult> Search([FromQuery] string q)
@@ -42,67 +17,13 @@ public class MemoryController : ControllerBase
if (string.IsNullOrWhiteSpace(q) || q.Length < 2) if (string.IsNullOrWhiteSpace(q) || q.Length < 2)
return Results.BadRequest("Query must be at least 2 characters."); return Results.BadRequest("Query must be at least 2 characters.");
var basePath = "/mnt/workspace-iris/memory"; return Results.Ok(await memoryService.SearchAsync(q));
var results = new List<object>();
const int maxFiles = 50;
const int maxFileSize = 1_000_000;
async Task SearchDir(string dir)
{
if (!Directory.Exists(dir)) return;
var files = Directory.GetFiles(dir, "*.md").Take(maxFiles);
foreach (var file in files)
{
var fi = new FileInfo(file);
if (fi.Length > maxFileSize) continue;
string content;
using (var reader = new StreamReader(file))
content = await reader.ReadToEndAsync();
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
results.Add(new { name = Path.GetFileName(file), path = file.Replace(basePath, "").TrimStart('/'), excerpt, size = fi.Length });
}
}
}
await SearchDir(basePath);
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (System.IO.File.Exists(longTermPath))
{
string content;
using (var reader = new StreamReader(longTermPath))
content = await reader.ReadToEndAsync();
if (content.Contains(q, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(q, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "\u2026" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "\u2026";
results.Insert(0, new { name = "MEMORY.md", path = "MEMORY.md", excerpt, size = content.Length });
}
}
return Results.Ok(results);
} }
[HttpGet("{name}")] [HttpGet("{name}")]
public async Task<IResult> GetFile(string name) public async Task<IResult> GetFile(string name)
{ {
if (!PathSecurityHelper.TryResolveSafePath("/mnt/workspace-iris/memory", name, out var filePath)) var file = await memoryService.GetFileAsync(name);
return Results.BadRequest("Invalid filename."); return file is null ? Results.NotFound() : Results.Ok(file);
var longTermPath = "/mnt/workspace-iris/MEMORY.md";
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
filePath = longTermPath;
if (!System.IO.File.Exists(filePath!))
return Results.NotFound();
var content = await System.IO.File.ReadAllTextAsync(filePath!);
return Results.Ok(new { name, path = name, content, size = content.Length, modifiedAt = System.IO.File.GetLastWriteTimeUtc(filePath!) });
} }
} }
+4 -60
View File
@@ -1,71 +1,15 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Services; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/operations")] [Route("api/v1/operations")]
public class OperationsController( public class OperationsController(IOperationsService operationsService) : ControllerBase
IAgentRuntime runtime,
IAgentService agentService,
IProjectRepository projectRepo,
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ControllerBase
{ {
[HttpGet("snapshot")] [HttpGet("snapshot")]
[Authorize]
public async Task<IResult> GetSnapshot(CancellationToken ct) public async Task<IResult> GetSnapshot(CancellationToken ct)
{ => Results.Ok(await operationsService.GetSnapshotAsync(ct));
var runtimeTask = runtime.GetStatusAsync(ct);
var agentsTask = agentService.GetAgentsAsync(ct);
var projectsTask = projectRepo.GetAllAsync(ct);
var tasksTask = taskRepo.GetAllAsync(ct);
var activityTask = activityRepo.GetRecentAsync(20, ct);
await Task.WhenAll(runtimeTask, agentsTask, projectsTask, tasksTask, activityTask);
var tasks = tasksTask.Result;
var projects = projectsTask.Result;
var agents = agentsTask.Result;
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
var runtimeStatus = runtimeTask.Result;
var runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online;
var lastIncident = tasks
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
.FirstOrDefault();
var projectHealth = new
{
Online = projects.Count(x => x.Status == OperationalStatus.Online),
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
};
return Results.Ok(new
{
generatedAt = DateTimeOffset.UtcNow,
runtime = runtimeStatus,
models = Array.Empty<object>(),
runtimeHealthy,
metrics = new
{
activeAgents = agents.Count,
queuedTasks = tasks.Count - completedTasks,
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
},
lastIncident,
projectHealth,
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
activity = activityTask.Result.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
});
}
} }
+19 -46
View File
@@ -1,17 +1,23 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Repositories; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/projects")] [Route("api/v1/projects")]
public class ProjectsController(IProjectRepository projectRepo, IActivityRepository activityRepo) : ControllerBase public class ProjectsController(IProjectService projectService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await projectRepo.GetAllAsync(ct)); => Results.Ok(await projectService.GetAllAsync(ct));
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
{
var project = await projectService.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
}
[HttpPost] [HttpPost]
public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct) public async Task<IResult> Create([FromBody] CreateProjectRequest request, CancellationToken ct)
@@ -19,59 +25,26 @@ public class ProjectsController(IProjectRepository projectRepo, IActivityReposit
if (string.IsNullOrWhiteSpace(request.Name)) if (string.IsNullOrWhiteSpace(request.Name))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["name"] = ["Name is required."] });
var project = new Project var project = await projectService.CreateAsync(request, ct);
{
Name = request.Name.Trim(),
Description = request.Description?.Trim() ?? string.Empty,
Status = OperationalStatus.Online
};
await projectRepo.AddAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
return Results.Created($"/api/v1/projects/{project.Id}", project); return Results.Created($"/api/v1/projects/{project.Id}", project);
} }
[HttpGet("{id:guid}")]
public async Task<IResult> GetById(Guid id, CancellationToken ct)
{
var project = await projectRepo.GetByIdAsync(id, ct);
return project is null ? Results.NotFound() : Results.Ok(project);
}
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct) public async Task<IResult> Update(Guid id, [FromBody] UpdateProjectRequest request, CancellationToken ct)
{ {
var project = await projectRepo.GetByIdAsync(id, ct); var project = await projectService.UpdateAsync(id, request, ct);
if (project is null) return Results.NotFound(); return project is null ? Results.NotFound() : Results.Ok(project);
if (!string.IsNullOrWhiteSpace(request.Name))
project.Name = request.Name.Trim();
if (request.Description is not null)
project.Description = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
project.Status = parsedStatus;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
return Results.Ok(project);
} }
[HttpDelete("{id:guid}")] [HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct) public async Task<IResult> Delete(Guid id, CancellationToken ct)
{ {
var project = await projectRepo.GetByIdAsync(id, ct); var result = await projectService.DeleteAsync(id, ct);
if (project is null) return Results.NotFound(); return result.Outcome switch
var hasTasks = await projectRepo.HasTasksAsync(id, ct);
if (hasTasks)
{ {
project.Status = OperationalStatus.Offline; ProjectDeleteOutcome.NotFound => Results.NotFound(),
await projectRepo.UpdateAsync(project, ct); ProjectDeleteOutcome.Archived => Results.Ok(result.Project),
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct); _ => Results.NoContent()
return Results.Ok(project); };
}
await projectRepo.DeleteAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
return Results.NoContent();
} }
} }
+48 -71
View File
@@ -1,17 +1,17 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Nexus.Api.Data; using Nexus.Api.Data;
using Nexus.Api.DTOs; using Nexus.Api.DTOs;
using Nexus.Api.Repositories; using Nexus.Api.Services;
namespace Nexus.Api.Controllers; namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/tasks")] [Route("api/v1/tasks")]
public class TasksController(ITaskRepository taskRepo, IActivityRepository activityRepo) : ControllerBase public class TasksController(ITaskService taskService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetAll(CancellationToken ct) public async Task<IResult> GetAll(CancellationToken ct)
=> Results.Ok(await taskRepo.GetAllAsync(ct)); => Results.Ok(await taskService.GetAllAsync(ct));
[HttpPost] [HttpPost]
public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct) public async Task<IResult> Create([FromBody] CreateTaskRequest request, CancellationToken ct)
@@ -19,107 +19,84 @@ public class TasksController(ITaskRepository taskRepo, IActivityRepository activ
if (string.IsNullOrWhiteSpace(request.Title)) if (string.IsNullOrWhiteSpace(request.Title))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["title"] = ["Title is required."] });
var task = new WorkTask var task = await taskService.CreateAsync(request, ct);
{
Title = request.Title.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
return Results.Created($"/api/v1/tasks/{task.Id}", task); return Results.Created($"/api/v1/tasks/{task.Id}", task);
} }
[HttpGet("pending-approval")] [HttpGet("pending-approval")]
public async Task<IResult> GetPendingApproval(CancellationToken ct) public async Task<IResult> GetPendingApproval(CancellationToken ct)
{ {
var pending = await taskRepo.GetPendingApprovalAsync(ct); var pending = await taskService.GetPendingApprovalAsync(ct);
return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt })); return Results.Ok(pending.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }));
} }
[HttpPost("{id:guid}/approve")] [HttpPost("{id:guid}/approve")]
public async Task<IResult> Approve(Guid id, CancellationToken ct) public async Task<IResult> Approve(Guid id, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.ApproveAsync(id, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) TaskOperationOutcome.NotFound => Results.NotFound(),
return Results.Problem( TaskOperationOutcome.InvalidState => Results.Problem(
title: "Approval denied", title: "Approval denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.", detail: "Only tasks in 'In progress' or 'Blocked' state can be approved.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
task.State = TaskStateHelper.ToStateString(TaskState.Done); };
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
return Results.Ok(task);
} }
[HttpPost("{id:guid}/reject")] [HttpPost("{id:guid}/reject")]
public async Task<IResult> Reject(Guid id, CancellationToken ct) public async Task<IResult> Reject(Guid id, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.RejectAsync(id, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
if (!TaskStateHelper.IsInProgressOrBlocked(task.State)) TaskOperationOutcome.NotFound => Results.NotFound(),
return Results.Problem( TaskOperationOutcome.InvalidState => Results.Problem(
title: "Rejection denied", title: "Rejection denied",
detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.", detail: "Only tasks in 'In progress' or 'Blocked' state can be rejected.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden),
_ => Results.Ok(result.Task)
task.State = TaskStateHelper.ToStateString(TaskState.Backlog); };
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
return Results.Ok(task);
} }
[HttpPatch("{id:guid}/state")] [HttpPatch("{id:guid}/state")]
public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct) public async Task<IResult> UpdateState(Guid id, [FromBody] UpdateTaskStateRequest request, CancellationToken ct)
{ {
var allowedStates = TaskStateHelper.AllStates; if (!TaskStateHelper.IsValidState(request.State))
if (!allowedStates.Contains(request.State, StringComparer.OrdinalIgnoreCase))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] }); return Results.ValidationProblem(new Dictionary<string, string[]> { ["state"] = ["Unsupported task state."] });
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.UpdateStateAsync(id, request.State, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
task.State = allowedStates.First(x => x.Equals(request.State, StringComparison.OrdinalIgnoreCase)); {
await taskRepo.UpdateAsync(task, ct); TaskOperationOutcome.NotFound => Results.NotFound(),
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct); _ => Results.Ok(result.Task)
return Results.Ok(task); };
}
[HttpDelete("{id:guid}")]
public async Task<IResult> Delete(Guid id, CancellationToken ct)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return Results.NotFound();
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return Results.Problem(
title: "Task deletion denied",
detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await taskRepo.DeleteAsync(task, ct);
return Results.NoContent();
} }
[HttpPatch("{id:guid}")] [HttpPatch("{id:guid}")]
public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct) public async Task<IResult> Update(Guid id, [FromBody] UpdateTaskRequest request, CancellationToken ct)
{ {
var task = await taskRepo.GetByIdAsync(id, ct); var result = await taskService.UpdateAsync(id, request, ct);
if (task is null) return Results.NotFound(); return result.Outcome switch
{
TaskOperationOutcome.NotFound => Results.NotFound(),
_ => Results.Ok(result.Task)
};
}
if (!string.IsNullOrWhiteSpace(request.Title)) [HttpDelete("{id:guid}")]
task.Title = request.Title.Trim(); public async Task<IResult> Delete(Guid id, CancellationToken ct)
if (!string.IsNullOrWhiteSpace(request.Priority)) {
task.Priority = request.Priority.Trim(); var result = await taskService.DeleteAsync(id, ct);
if (request.ProjectId.HasValue) return result.Outcome switch
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId; {
TaskOperationOutcome.NotFound => Results.NotFound(),
await taskRepo.UpdateAsync(task, ct); TaskOperationOutcome.InvalidState => Results.Problem(
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct); title: "Task deletion denied",
return Results.Ok(task); detail: "Only tasks in 'Done' or 'Backlog' state can be deleted.",
statusCode: StatusCodes.Status403Forbidden),
_ => Results.NoContent()
};
} }
} }
+2 -29
View File
@@ -5,36 +5,9 @@ namespace Nexus.Api.Controllers;
[ApiController] [ApiController]
[Route("api/v1/team")] [Route("api/v1/team")]
public class TeamController(IAgentService agentService) : ControllerBase public class TeamController(ITeamService teamService) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IResult> GetTeam(CancellationToken ct) public async Task<IResult> GetTeam(CancellationToken ct)
{ => Results.Ok(await teamService.GetTeamAsync(ct));
var agents = await agentService.GetAgentsAsync(ct);
var team = new List<object>();
foreach (var agent in agents)
{
string identity = "";
string workspace = agent.Workspace ?? "";
if (!string.IsNullOrWhiteSpace(workspace) && Directory.Exists(workspace))
{
var identityFile = Path.Combine(workspace, "IDENTITY.md");
if (System.IO.File.Exists(identityFile))
{
var content = await System.IO.File.ReadAllTextAsync(identityFile, ct);
var lines = content.Split('\n').Where(l => l.StartsWith("- **")).Take(8);
identity = string.Join("\n", lines);
}
}
team.Add(new
{
agent.Id, agent.Name, agent.Role, agent.Model, agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
identity
});
}
return Results.Ok(team);
}
} }
+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; }
}
+4
View File
@@ -77,9 +77,13 @@ public sealed class WorkTask
{ {
public Guid Id { get; init; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public required string Title { get; set; } public required string Title { get; set; }
public string? Detail { get; set; }
public string State { get; set; } = "Backlog"; public string State { get; set; } = "Backlog";
public string Priority { get; set; } = "Normal"; public string Priority { get; set; } = "Normal";
public string Source { get; set; } = "bao";
public string? AssignedTo { get; set; }
public Guid? ProjectId { get; set; } public Guid? ProjectId { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
} }
@@ -0,0 +1,240 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Nexus.Api.Data;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Nexus.Api.Migrations
{
[DbContext(typeof(NexusDbContext))]
[Migration("20260611154800_AddTaskDetailFields")]
partial class AddTaskDetailFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Nexus.Api.Data.ActivityEvent", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Activity");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("Nexus.Api.Data.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("character varying(160)");
b.Property<int>("Progress")
.HasColumnType("integer");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FamilyId")
.HasColumnType("uuid");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId", "FamilyId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("Nexus.Api.Data.WorkTask", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Priority")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("ProjectId")
.HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("character varying(240)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("Source");
b.ToTable("Tasks");
});
modelBuilder.Entity("Nexus.Api.Data.RefreshToken", b =>
{
b.HasOne("Nexus.Api.Data.NexusUser", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Nexus.Api.Data.NexusUser", b =>
{
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Nexus.Api.Migrations
{
/// <inheritdoc />
public partial class AddTaskDetailFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AssignedTo",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "CreatedAt",
table: "Tasks",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "NOW()");
migrationBuilder.AddColumn<string>(
name: "Detail",
table: "Tasks",
type: "character varying(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Source",
table: "Tasks",
type: "character varying(60)",
maxLength: 60,
nullable: false,
defaultValue: "bao");
migrationBuilder.CreateIndex(
name: "IX_Tasks_AssignedTo",
table: "Tasks",
column: "AssignedTo");
migrationBuilder.CreateIndex(
name: "IX_Tasks_Source",
table: "Tasks",
column: "Source");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Tasks_AssignedTo",
table: "Tasks");
migrationBuilder.DropIndex(
name: "IX_Tasks_Source",
table: "Tasks");
migrationBuilder.DropColumn(
name: "AssignedTo",
table: "Tasks");
migrationBuilder.DropColumn(
name: "CreatedAt",
table: "Tasks");
migrationBuilder.DropColumn(
name: "Detail",
table: "Tasks");
migrationBuilder.DropColumn(
name: "Source",
table: "Tasks");
}
}
}
@@ -172,6 +172,17 @@ namespace Nexus.Api.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("AssignedTo")
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("Priority") b.Property<string>("Priority")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -179,6 +190,11 @@ namespace Nexus.Api.Migrations
b.Property<Guid?>("ProjectId") b.Property<Guid?>("ProjectId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("character varying(60)");
b.Property<string>("State") b.Property<string>("State")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -193,6 +209,10 @@ namespace Nexus.Api.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("AssignedTo");
b.HasIndex("Source");
b.ToTable("Tasks"); b.ToTable("Tasks");
}); });
+9 -1
View File
@@ -13,7 +13,15 @@ public sealed class NexusDbContext(DbContextOptions<NexusDbContext> options) : D
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160); modelBuilder.Entity<Project>().Property(x => x.Name).HasMaxLength(160);
modelBuilder.Entity<WorkTask>().Property(x => x.Title).HasMaxLength(240); modelBuilder.Entity<WorkTask>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(240);
entity.Property(x => x.Detail).HasMaxLength(2000);
entity.Property(x => x.Source).HasMaxLength(60);
entity.Property(x => x.AssignedTo).HasMaxLength(60);
entity.HasIndex(x => x.Source);
entity.HasIndex(x => x.AssignedTo);
});
modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000); modelBuilder.Entity<ActivityEvent>().Property(x => x.Message).HasMaxLength(1000);
modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique(); modelBuilder.Entity<NexusUser>().HasIndex(u => u.NormalizedEmail).IsUnique();
modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique(); modelBuilder.Entity<RefreshToken>().HasIndex(r => r.TokenHash).IsUnique();
+1
View File
@@ -8,6 +8,7 @@ RUN dotnet publish -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app WORKDIR /app
COPY --from=build /app/publish . COPY --from=build /app/publish .
RUN apk add --no-cache curl
USER $APP_UID USER $APP_UID
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["dotnet", "Nexus.Api.dll"] ENTRYPOINT ["dotnet", "Nexus.Api.dll"]
@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore;
using Nexus.Api.Data;
using Nexus.Api.Helpers;
using Nexus.Api.Middleware;
using Nexus.Api.Services;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for configuring the Nexus application pipeline and startup.
/// </summary>
public static class ApplicationBuilderExtensions
{
/// <summary>
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
/// </summary>
public static async Task EnsureDatabaseAsync(this WebApplication app)
{
var configuration = app.Configuration;
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = configuration["Owner:Password"];
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? PasswordHelper.GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
}
}
/// <summary>
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
/// </summary>
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseForwardedHeaders();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (env.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
return app;
}
}
@@ -0,0 +1,214 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
namespace Nexus.Api.Extensions;
/// <summary>
/// Extension methods for registering Nexus application services in the DI container.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Configures JWT authentication, authorization, and antiforgery.
/// </summary>
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
{
var jwtKey = configuration["Jwt:Key"];
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
services.AddAuthorization();
services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
return services;
}
/// <summary>
/// Configures rate limiting policies (auth and agents).
/// </summary>
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
return services;
}
/// <summary>
/// Configures forwarded headers for reverse proxy scenarios.
/// </summary>
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
return services;
}
/// <summary>
/// Configures Swagger and JSON serialization options.
/// </summary>
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen();
services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
return services;
}
/// <summary>
/// Registers the Entity Framework Core DbContext with Npgsql.
/// </summary>
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
return services;
}
/// <summary>
/// Registers typed and named HTTP clients for OpenClaw integration.
/// </summary>
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
{
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(120);
});
return services;
}
/// <summary>
/// Registers application domain services (transient, scoped, singleton).
/// </summary>
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
{
services.AddTransient<ModelRoutingService>();
services.AddScoped<IAuthService, AuthService>();
services.AddScoped<IAgentService, AgentService>();
services.AddScoped<IDashboardService, DashboardService>();
services.AddScoped<IProjectService, ProjectService>();
services.AddScoped<ITaskService, TaskService>();
services.AddScoped<IOperationsService, OperationsService>();
services.AddScoped<ITeamService, TeamService>();
services.AddSingleton<IAgentConfigService, AgentConfigService>();
services.AddSingleton<IMemoryService, MemoryService>();
services.AddSingleton<IIncidentService, IncidentService>();
services.AddSingleton<IDocService, DocService>();
services.AddScoped<ICalendarService, CalendarService>();
return services;
}
/// <summary>
/// Registers data repositories.
/// </summary>
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
{
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<ITaskRepository, TaskRepository>();
services.AddScoped<IActivityRepository, ActivityRepository>();
return services;
}
/// <summary>
/// Configures health checks (PostgreSQL connectivity and runtime status).
/// </summary>
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
{
services.AddHealthChecks()
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
return services;
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
namespace Nexus.Api.Helpers;
/// <summary>
/// Helper methods for password generation and name construction.
/// </summary>
public static class PasswordHelper
{
/// <summary>
/// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
/// </summary>
public static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
/// <summary>
/// Builds a human-readable display name from an email address.
/// </summary>
public static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
}
+116
View File
@@ -0,0 +1,116 @@
namespace Nexus.Api.Models;
public sealed record DashboardAgentInfo(
string Id,
string Name,
string Role,
string Model,
bool IsActive,
string? CurrentTask,
string? Description,
string[] Tags,
int Progress = 0,
int Workload = 0,
string? Goal = null,
string RoleBadge = "badge-slate",
string StatusLabel = "Bereit",
string? Elapsed = null,
string? Think = null,
string? Next = null
);
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,
string? AgentId = null,
string? Type = null
);
public sealed record DashboardStatus(
bool GatewayOk,
string IrisStatus,
int ActiveAgents,
int PendingTasks
);
public sealed record QueueItem(
string Id,
string Name,
string Status,
string Priority,
string Source,
string WaitTime
);
public sealed record AgentModelInfo(
string Model,
string Provider
);
public sealed record SetModelRequest(
string Model
);
public sealed record ModelOption(
string Id,
string Name,
string Provider
);
// ── Dashboard Task DTOs ──
public sealed record DashboardTaskDto(
Guid Id,
string Title,
string? Detail,
string Source,
string State,
string Priority,
string? AssignedTo,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt
);
public sealed record CreateDashboardTaskRequest(
string Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
);
public sealed record UpdateDashboardTaskRequest(
string? Title,
string? Detail,
string? Source,
string? Priority,
string? AssignedTo
);
public sealed record UpdateDashboardTaskStatusRequest(
string Status
);
public sealed record AgentActivityEntry(
string Time,
string Text
);
+14 -205
View File
@@ -1,217 +1,26 @@
using Microsoft.AspNetCore.Authentication.JwtBearer; using Nexus.Api.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens;
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Middleware;
using Nexus.Api.Repositories;
using Nexus.Api.Routing;
using Nexus.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// --- JWT Configuration --- // --- Service Registration ---
var jwtKey = builder.Configuration["Jwt:Key"]; builder.Services.AddNexusAuth(builder.Configuration);
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus"; builder.Services.AddNexusRateLimiting();
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web"; builder.Services.AddNexusForwardedHeaders();
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32) builder.Services.AddNexusSwagger();
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes."); builder.Services.AddNexusDatabase(builder.Configuration);
builder.Services.AddNexusHttpClients(builder.Configuration);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddNexusApplicationServices();
.AddJwtBearer(options => builder.Services.AddNexusRepositories();
{ builder.Services.AddNexusHealthChecks(builder.Configuration);
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
NameClaimType = JwtRegisteredClaimNames.Sub,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization();
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-CSRF-TOKEN";
options.Cookie.Name = "nexus-csrf";
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.HttpOnly = false;
});
// --- Rate Limiting ---
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
});
// --- Forwarded Headers ---
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
});
// --- Swagger & JSON ---
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// --- Database ---
builder.Services.AddDbContext<NexusDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
// --- HTTP Clients ---
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(5);
});
builder.Services.AddHttpClient("gateway", client =>
{
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
?? "http://127.0.0.1:18789");
client.Timeout = TimeSpan.FromSeconds(5);
});
// --- Application Services ---
builder.Services.AddTransient<ModelRoutingService>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IAgentService, AgentService>();
// --- Repositories ---
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
// --- Health Checks ---
builder.Services.AddHealthChecks()
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
// --- Controllers ---
builder.Services.AddControllers(); builder.Services.AddControllers();
var app = builder.Build(); var app = builder.Build();
// --- Database Migration & Owner Seeding --- // --- Database Migration & Seeding ---
await using (var scope = app.Services.CreateAsyncScope()) await app.EnsureDatabaseAsync();
{
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
await db.Database.MigrateAsync();
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
var ownerPassword = builder.Configuration["Owner:Password"];
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
var hasUsers = await db.Users.AnyAsync();
if (!hasUsers)
{
if (string.IsNullOrWhiteSpace(ownerEmail))
throw new InvalidOperationException("Owner:Email is required for initial setup.");
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
? BuildOwnerDisplayName(ownerEmail)
: ownerDisplayName;
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
? GenerateTemporaryPassword()
: ownerPassword;
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
db.Users.Add(new NexusUser
{
Email = ownerEmail,
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
DisplayName = initialDisplayName,
PasswordHash = PasswordSecurity.Hash(initialPassword),
Role = "owner"
});
await db.SaveChangesAsync();
if (string.IsNullOrWhiteSpace(ownerPassword))
{
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
}
}
}
// --- Middleware Pipeline --- // --- Middleware Pipeline ---
app.UseForwardedHeaders(); app.UseNexusPipeline(app.Environment);
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.UseSecurityHeaders();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
// --- Helpers ---
static string GenerateTemporaryPassword()
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
static string BuildOwnerDisplayName(string email)
{
var localPart = email.Split('@', 2)[0].Trim();
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
var words = localPart
.Replace('.', ' ')
.Replace('_', ' ')
.Replace('-', ' ')
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
var displayName = string.Join(' ', words);
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
}
+2 -3
View File
@@ -10,12 +10,11 @@ public interface IUserRepository
Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default); Task<NexusUser> AddAsync(NexusUser user, CancellationToken ct = default);
Task UpdateAsync(NexusUser user, CancellationToken ct = default); Task UpdateAsync(NexusUser user, CancellationToken ct = default);
// Refresh token operations
Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default); Task<RefreshToken?> GetRefreshTokenByHashAsync(string tokenHash, CancellationToken ct = default);
Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default); Task<List<RefreshToken>> GetActiveTokensByFamilyAsync(Guid familyId, CancellationToken ct = default);
Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); Task AddRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default); Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default);
Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default);
Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default);
Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default); Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
} }
+30 -3
View File
@@ -43,6 +43,33 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default) public Task UpdateRefreshTokenAsync(RefreshToken token, CancellationToken ct = default)
=> db.SaveChangesAsync(ct); => db.SaveChangesAsync(ct);
public async Task RevokeTokenAsync(string tokenHash, CancellationToken ct = default)
{
var token = await db.RefreshTokens.FirstOrDefaultAsync(r => r.TokenHash == tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await db.SaveChangesAsync(ct);
}
public async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct = default)
{
var activeTokens = await db.RefreshTokens
.Where(r => r.FamilyId == familyId && r.RevokedAt == null)
.ToListAsync(ct);
if (activeTokens.Count == 0) return;
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.ConcurrencyStamp = Guid.NewGuid();
}
await db.SaveChangesAsync(ct);
}
public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default) public async Task RemoveExpiredTokensAsync(Guid userId, CancellationToken ct = default)
{ {
var cutoff = DateTimeOffset.UtcNow.AddDays(-30); var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
@@ -51,9 +78,9 @@ public sealed class UserRepository(NexusDbContext db) : IUserRepository
.ToListAsync(ct); .ToListAsync(ct);
if (oldTokens.Count > 0) if (oldTokens.Count > 0)
{
db.RefreshTokens.RemoveRange(oldTokens); db.RefreshTokens.RemoveRange(oldTokens);
await db.SaveChangesAsync(ct);
}
} }
public Task SaveChangesAsync(CancellationToken ct = default)
=> db.SaveChangesAsync(ct);
} }
+64
View File
@@ -0,0 +1,64 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class AgentConfigService : IAgentConfigService
{
private static readonly HashSet<string> AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
{
"IDENTITY.md", "SOUL.md", "AGENTS.md", "TOOLS.md", "HEARTBEAT.md", "USER.md", "MEMORY.md"
};
public IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId)
{
var workspacePath = $"/mnt/workspace-{agentId}";
if (!Directory.Exists(workspacePath))
return Array.Empty<AgentConfigFileInfo>();
return Directory.GetFiles(workspacePath, "*.md")
.Select(f => new FileInfo(f))
.Where(f => AllowedFiles.Contains(f.Name))
.OrderBy(f => f.Name)
.Select(f => new AgentConfigFileInfo(f.Name, f.Length, f.LastWriteTimeUtc))
.ToList();
}
public async Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath) || !File.Exists(safePath))
return null;
var content = await File.ReadAllTextAsync(safePath!, ct);
var fi = new FileInfo(safePath!);
return new AgentConfigFileContent(fileName, content, fi.Length, fi.LastWriteTimeUtc);
}
public async Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default)
{
if (!PathSecurityHelper.IsValidConfigFileName(fileName))
return null;
var workspacePath = $"/mnt/workspace-{agentId}";
if (!PathSecurityHelper.TryResolveSafePath(workspacePath, fileName, out var safePath))
return null;
var tempPath = safePath + ".tmp";
try
{
await File.WriteAllTextAsync(tempPath, content, ct);
File.Move(tempPath, safePath!, overwrite: true);
}
catch
{
if (File.Exists(tempPath)) File.Delete(tempPath);
throw;
}
var fi = new FileInfo(safePath!);
return new AgentConfigFileSaveResult(fileName, fi.Length, fi.LastWriteTimeUtc);
}
}
+46 -27
View File
@@ -17,6 +17,7 @@ public interface IAuthService
Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default); Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default);
Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, 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> ChangePasswordAsync(Guid userId, ChangePasswordRequest request, CancellationToken ct = default);
Task<bool> AdminResetPasswordAsync(string email, string newPassword, string adminToken, CancellationToken ct = default);
} }
public sealed record AuthSession( public sealed record AuthSession(
@@ -31,6 +32,8 @@ public sealed class AuthService : IAuthService
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly ILogger<AuthService> _logger; private readonly ILogger<AuthService> _logger;
private static string AdminResetToken => Environment.GetEnvironmentVariable("Admin__ResetToken") ?? string.Empty;
public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger) public AuthService(IUserRepository users, IConfiguration config, ILogger<AuthService> logger)
{ {
_users = users; _users = users;
@@ -68,7 +71,7 @@ public sealed class AuthService : IAuthService
if (token.RevokedAt is not null) if (token.RevokedAt is not null)
{ {
await RevokeFamilyAsync(token.FamilyId, ct); await _users.RevokeFamilyAsync(token.FamilyId, ct);
_logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId); _logger.LogWarning("Refresh token reuse detected for family {FamilyId}", token.FamilyId);
return null; return null;
} }
@@ -81,23 +84,12 @@ public sealed class AuthService : IAuthService
public async Task RevokeAsync(string refreshToken, CancellationToken ct = default) public async Task RevokeAsync(string refreshToken, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(refreshToken)) return; if (string.IsNullOrWhiteSpace(refreshToken)) return;
var tokenHash = HashToken(refreshToken); var tokenHash = HashToken(refreshToken);
var token = await _users.GetRefreshTokenByHashAsync(tokenHash, ct); await _users.RevokeTokenAsync(tokenHash, ct);
if (token is null || token.RevokedAt is not null) return;
token.RevokedAt = DateTimeOffset.UtcNow;
token.ConcurrencyStamp = Guid.NewGuid();
await _users.SaveChangesAsync(ct);
} }
public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default) public Task<NexusUser?> GetUserAsync(Guid userId, CancellationToken ct = default)
=> Task.Run(async () => => _users.GetByIdAsync(userId, ct).AsTask();
{
// AsNoTracking equivalent: UserRepository.GetByIdAsync uses FindAsync (tracked by default)
// For read-only access, we call it but the result shouldn't be mutated
return await _users.GetByIdAsync(userId, ct);
}, ct);
public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default) public async Task<NexusUser?> UpdateProfileAsync(Guid userId, UpdateProfileRequest request, CancellationToken ct = default)
{ {
@@ -128,6 +120,46 @@ public sealed class AuthService : IAuthService
return true; 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( private async Task<AuthSession?> CreateSessionAsync(
NexusUser user, NexusUser user,
Guid familyId, Guid familyId,
@@ -185,19 +217,6 @@ public sealed class AuthService : IAuthService
return new JwtSecurityTokenHandler().WriteToken(token); return new JwtSecurityTokenHandler().WriteToken(token);
} }
private async Task RevokeFamilyAsync(Guid familyId, CancellationToken ct)
{
var activeTokens = await _users.GetActiveTokensByFamilyAsync(familyId, ct);
var now = DateTimeOffset.UtcNow;
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.ConcurrencyStamp = Guid.NewGuid();
}
await _users.SaveChangesAsync(ct);
}
private static string GenerateRefreshToken() private static string GenerateRefreshToken()
{ {
var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64)); var value = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
+86
View File
@@ -0,0 +1,86 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public sealed class CalendarService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<CalendarService> logger) : ICalendarService
{
public async Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<CronJobEntry>>(ct);
return data ?? new List<CronJobEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway cron endpoint not reachable, using fallback data");
}
return BuildFallbackCronJobs();
}
public async Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default)
{
try
{
var client = CreateGatewayClient();
var response = await client.GetAsync("/api/cron/upcoming", ct);
if (response.IsSuccessStatusCode)
{
var data = await response.Content.ReadFromJsonAsync<List<UpcomingCronEntry>>(ct);
return data ?? new List<UpcomingCronEntry>();
}
}
catch (Exception ex)
{
logger.LogDebug(ex, "Gateway upcoming cron endpoint not reachable, using fallback data");
}
return BuildFallbackUpcomingJobs();
}
private HttpClient CreateGatewayClient()
{
var client = httpClientFactory.CreateClient("gateway");
var token = configuration["Integrations:OpenClaw:Token"];
if (!string.IsNullOrWhiteSpace(token))
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static IReadOnlyList<CronJobEntry> BuildFallbackCronJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", "*/5 * * * *", now.AddMinutes(-3).ToString("O"), now.AddMinutes(2).ToString("O"), "completed"),
new("memory-sync", "Memory Sync", "0 */6 * * *", now.AddHours(-2).ToString("O"), now.AddHours(4).ToString("O"), "completed"),
new("task-cleanup", "Task Cleanup", "0 3 * * *", now.AddDays(-1).ToString("O"), now.AddDays(1).AddHours(3).ToString("O"), "completed"),
new("backup", "Database Backup", "0 4 * * *", now.AddDays(-1).AddHours(-1).ToString("O"), now.AddDays(1).AddHours(4).ToString("O"), "completed"),
new("model-routing-refresh", "Model Routing Refresh", "*/30 * * * *", now.AddMinutes(-12).ToString("O"), now.AddMinutes(18).ToString("O"), "running")
];
}
private static IReadOnlyList<UpcomingCronEntry> BuildFallbackUpcomingJobs()
{
var now = DateTimeOffset.UtcNow;
return
[
new("health-check", "Health Check", now.AddMinutes(2).ToString("O"), "*/5 * * * *"),
new("model-routing-refresh", "Model Routing Refresh", now.AddMinutes(18).ToString("O"), "*/30 * * * *"),
new("memory-sync", "Memory Sync", now.AddHours(4).ToString("O"), "0 */6 * * *"),
new("task-cleanup", "Task Cleanup", now.AddDays(1).AddHours(3).ToString("O"), "0 3 * * *"),
new("backup", "Database Backup", now.AddDays(1).AddHours(4).ToString("O"), "0 4 * * *")
];
}
}
+209
View File
@@ -0,0 +1,209 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public sealed class DashboardService(
IOpenClawGatewayClient gateway,
ITaskService taskService,
ILogger<DashboardService> logger) : IDashboardService
{
public async Task<DashboardStatus> GetStatusAsync()
{
try
{
return await gateway.GetStatusAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard status check failed");
return new DashboardStatus(false, "Offline", 0, 0);
}
}
public async Task<List<DashboardAgentInfo>> GetAgentsAsync()
{
try
{
return await gateway.GetAgentsAsync();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard agents fetch failed");
return [];
}
}
public async Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter)
{
try
{
var entries = await gateway.GetAllAgentOperationsAsync(Math.Clamp(limit, 1, 100));
if (!string.IsNullOrWhiteSpace(agentFilter))
{
entries = entries
.Where(e => string.Equals(e.AgentId, agentFilter, StringComparison.OrdinalIgnoreCase)
|| string.Equals(e.Agent, agentFilter, StringComparison.OrdinalIgnoreCase))
.ToList();
}
return entries;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard operations fetch failed");
return [];
}
}
public async Task<ChatResponse> SendChatAsync(string agentId, string message)
{
try
{
return await gateway.SendChatMessageAsync(agentId, message);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard chat send failed");
return new ChatResponse(false, null, "Gateway nicht erreichbar");
}
}
public async Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset)
{
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 [];
}
}
public async Task<List<QueueItem>> GetQueueAsync(CancellationToken ct)
{
try
{
var cronTask = gateway.GetQueueAsync();
var tasksTask = taskService.GetOpenAsync(ct);
await Task.WhenAll(cronTask, tasksTask);
var merged = new List<QueueItem>(cronTask.Result);
foreach (var t in tasksTask.Result)
{
merged.Add(new QueueItem("task-" + t.Id, t.Title, t.State, NormalizePriority(t.Priority), "task", "--"));
}
return merged
.OrderBy(q => PriorityOrder.GetValueOrDefault(q.Priority, 99))
.ToList();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Dashboard queue fetch failed");
return [];
}
}
public async Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct)
{
if (string.Equals(source, "cron", StringComparison.OrdinalIgnoreCase))
{
var ok = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(ok ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.GatewayError);
}
if (string.Equals(source, "task", StringComparison.OrdinalIgnoreCase) || id.StartsWith("task-"))
{
if (!id.StartsWith("task-")) return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
if (!Guid.TryParse(id["task-".Length..], out var guid))
return new QueueDeleteResult(QueueDeleteOutcome.InvalidTaskId);
var result = await taskService.CompleteViaQueueAsync(guid, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => new QueueDeleteResult(QueueDeleteOutcome.TaskNotFound),
_ => new QueueDeleteResult(QueueDeleteOutcome.Deleted)
};
}
var deleted = await gateway.DeleteCronJobAsync(id);
return new QueueDeleteResult(deleted ? QueueDeleteOutcome.Deleted : QueueDeleteOutcome.NotFound);
}
public async Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct)
{
if (!id.StartsWith("task-"))
return new QueuePriorityResult(QueuePriorityOutcome.Ignored);
if (!Guid.TryParse(id["task-".Length..], out var guid))
return new QueuePriorityResult(QueuePriorityOutcome.InvalidTaskId);
var result = await taskService.CyclePriorityAsync(guid, ct);
return result.Outcome switch
{
TaskOperationOutcome.NotFound => new QueuePriorityResult(QueuePriorityOutcome.TaskNotFound),
_ => new QueuePriorityResult(QueuePriorityOutcome.Updated, result.Task?.Priority)
};
}
public async Task<AgentModelInfo?> GetAgentModelAsync(string agentId)
{
try
{
return await gateway.GetAgentModelAsync(agentId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentModel failed for {AgentId}", agentId);
return null;
}
}
public async Task<bool> SetAgentModelAsync(string agentId, string model)
{
try
{
return await gateway.SetAgentModelAsync(agentId, model);
}
catch (Exception ex)
{
logger.LogWarning(ex, "SetAgentModel failed for {AgentId}", agentId);
return false;
}
}
public async Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit)
{
try
{
return await gateway.GetAgentActivityAsync(agentId, Math.Clamp(limit, 1, 20));
}
catch (Exception ex)
{
logger.LogWarning(ex, "GetAgentActivity failed for {AgentId}", agentId);
return [];
}
}
public List<ModelOption> GetAvailableModels() => gateway.GetAvailableModels();
private static string NormalizePriority(string priority) => priority.ToLowerInvariant() switch
{
"high" or "critical" or "urgent" => "high",
"low" or "minor" => "low",
_ => "medium"
};
private static readonly Dictionary<string, int> PriorityOrder = new(StringComparer.OrdinalIgnoreCase)
{
["high"] = 0, ["medium"] = 1, ["low"] = 2
};
}
+75
View File
@@ -0,0 +1,75 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class DocService : IDocService
{
private static readonly string[] AllowedExtensions = [".md", ".json", ".txt", ".yaml", ".yml", ".html", ".css"];
private static readonly string[] SearchRoots =
[
"/mnt/workspace-iris",
"/home/node/.openclaw/workspace/nexus"
];
private static readonly (string Dir, string Category)[] ScanDirectories =
[
("/mnt/workspace-iris/nexus-phases", "phases"),
("/mnt/workspace-iris/skills", "skills"),
("/mnt/workspace-iris", "workspace"),
("/home/node/.openclaw/workspace/nexus", "nexus"),
("/home/node/.openclaw/workspace/nexus/phases", "nexus-phases")
];
public IReadOnlyList<DocFileInfo> GetAll()
{
var results = new List<DocFileInfo>();
foreach (var (dir, category) in ScanDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.GetFiles(dir, "*.*"))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext)) continue;
var fi = new FileInfo(file);
results.Add(new DocFileInfo(
fi.Name,
file.Replace("/mnt/workspace-iris", "").TrimStart('/'),
category,
ext.Replace(".", ""),
fi.Length,
fi.LastWriteTimeUtc));
}
}
return results.OrderByDescending(x => x.ModifiedAt).Take(100).ToList();
}
public async Task<DocFileContent?> GetFileAsync(string path)
{
if (string.IsNullOrWhiteSpace(path))
return null;
string? resolvedPath = null;
foreach (var root in SearchRoots)
{
if (PathSecurityHelper.TryResolveSafePath(root, path, out var candidate) && File.Exists(candidate))
{
resolvedPath = candidate;
break;
}
}
if (resolvedPath is null)
return null;
var content = await File.ReadAllTextAsync(resolvedPath);
var fi = new FileInfo(resolvedPath);
var relativePath = resolvedPath
.Replace("/mnt/workspace-iris/", "")
.Replace("/home/node/.openclaw/workspace/nexus/", "");
return new DocFileContent(fi.Name, relativePath, content, fi.Length, fi.LastWriteTimeUtc);
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace Nexus.Api.Services;
public sealed record AgentConfigFileInfo(string FileName, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileContent(string FileName, string Content, long Size, DateTime ModifiedAt);
public sealed record AgentConfigFileSaveResult(string FileName, long Size, DateTime ModifiedAt);
public interface IAgentConfigService
{
IReadOnlyList<AgentConfigFileInfo> GetConfigFiles(string agentId);
Task<AgentConfigFileContent?> GetConfigFileAsync(string agentId, string fileName, CancellationToken ct = default);
Task<AgentConfigFileSaveResult?> SaveConfigFileAsync(string agentId, string fileName, string content, CancellationToken ct = default);
}
+9
View File
@@ -0,0 +1,9 @@
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public interface ICalendarService
{
Task<IReadOnlyList<CronJobEntry>> GetCronJobsAsync(CancellationToken ct = default);
Task<IReadOnlyList<UpcomingCronEntry>> GetUpcomingCronJobsAsync(CancellationToken ct = default);
}
+25
View File
@@ -0,0 +1,25 @@
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public enum QueueDeleteOutcome { Deleted, NotFound, GatewayError, TaskNotFound, InvalidTaskId, Ignored }
public enum QueuePriorityOutcome { Updated, Ignored, TaskNotFound, InvalidTaskId }
public sealed record QueueDeleteResult(QueueDeleteOutcome Outcome);
public sealed record QueuePriorityResult(QueuePriorityOutcome Outcome, string? NewPriority = null);
public interface IDashboardService
{
Task<DashboardStatus> GetStatusAsync();
Task<List<DashboardAgentInfo>> GetAgentsAsync();
Task<List<FeedEntry>> GetOperationsAsync(int limit, string? agentFilter);
Task<ChatResponse> SendChatAsync(string agentId, string message);
Task<List<MessageEntry>> GetMessagesAsync(string? sessionKey, int limit, int offset);
Task<List<QueueItem>> GetQueueAsync(CancellationToken ct);
Task<QueueDeleteResult> DeleteQueueItemAsync(string id, string? source, CancellationToken ct);
Task<QueuePriorityResult> CycleQueuePriorityAsync(string id, CancellationToken ct);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit);
List<ModelOption> GetAvailableModels();
}
+22
View File
@@ -0,0 +1,22 @@
namespace Nexus.Api.Services;
public sealed record DocFileInfo(
string Name,
string Path,
string Category,
string Type,
long Size,
DateTime ModifiedAt);
public sealed record DocFileContent(
string Name,
string Path,
string Content,
long Size,
DateTime ModifiedAt);
public interface IDocService
{
IReadOnlyList<DocFileInfo> GetAll();
Task<DocFileContent?> GetFileAsync(string path);
}
+22
View File
@@ -0,0 +1,22 @@
namespace Nexus.Api.Services;
public sealed record IncidentSummary(
string Name,
string Title,
string? Date,
string Severity,
string Excerpt,
long Size);
public sealed record IncidentDetail(
string Name,
string Title,
string? Date,
string Content,
long Size);
public interface IIncidentService
{
Task<IReadOnlyList<IncidentSummary>> GetAllAsync();
Task<IncidentDetail?> GetByNameAsync(string name);
}
+14
View File
@@ -0,0 +1,14 @@
namespace Nexus.Api.Services;
public sealed record MemoryFileInfo(string Name, string Path, long Size, DateTime ModifiedAt);
public sealed record MemoryFileContent(string Name, string Path, string Content, long Size, DateTime ModifiedAt);
public sealed record MemorySearchResult(string Name, string Path, string Excerpt, long Size);
public interface IMemoryService
{
Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync();
Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query);
Task<MemoryFileContent?> GetFileAsync(string name);
}
@@ -0,0 +1,20 @@
using System.Text.Json.Nodes;
using Nexus.Api.Models;
namespace Nexus.Api.Services;
public interface IOpenClawGatewayClient
{
Task<JsonNode?> InvokeToolAsync(string tool, object? args = null);
Task<DashboardStatus> GetStatusAsync();
Task<List<DashboardAgentInfo>> GetAgentsAsync();
Task<List<MessageEntry>> GetSessionHistoryAsync(string sessionKey, int limit = 50, int offset = 0);
Task<List<FeedEntry>> GetAllAgentOperationsAsync(int limit = 30);
Task<ChatResponse> SendChatMessageAsync(string agentId, string message);
Task<List<QueueItem>> GetQueueAsync();
Task<bool> DeleteCronJobAsync(string id);
Task<AgentModelInfo?> GetAgentModelAsync(string agentId);
Task<bool> SetAgentModelAsync(string agentId, string model);
Task<List<AgentActivityEntry>> GetAgentActivityAsync(string agentId, int limit = 5);
List<ModelOption> GetAvailableModels();
}
+6
View File
@@ -0,0 +1,6 @@
namespace Nexus.Api.Services;
public interface IOperationsService
{
Task<object> GetSnapshotAsync(CancellationToken ct = default);
}
+17
View File
@@ -0,0 +1,17 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public enum ProjectDeleteOutcome { NotFound, Deleted, Archived }
public sealed record ProjectDeleteResult(ProjectDeleteOutcome Outcome, Project? Project = null);
public interface IProjectService
{
Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default);
Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default);
Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default);
Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default);
}
+29
View File
@@ -0,0 +1,29 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
namespace Nexus.Api.Services;
public enum TaskOperationOutcome { Success, NotFound, InvalidState }
public sealed record TaskOperationResult(TaskOperationOutcome Outcome, WorkTask? Task = null);
public interface ITaskService
{
Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default);
Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default);
Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default);
Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default);
Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default);
Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default);
// Dashboard-facing task operations
Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default);
Task<WorkTask> CreateDashboardTaskAsync(string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
Task<TaskOperationResult> UpdateDashboardTaskAsync(Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default);
Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default);
Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default);
Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default);
}
+19
View File
@@ -0,0 +1,19 @@
using Nexus.Api.Data;
namespace Nexus.Api.Services;
public sealed record TeamMember(
string Id,
string Name,
string Role,
string Model,
OperationalStatus Status,
DateTimeOffset? LastSeen,
string? Workspace,
string? Description,
string Identity);
public interface ITeamService
{
Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default);
}
+89
View File
@@ -0,0 +1,89 @@
using Nexus.Api.Helpers;
using System.Text.RegularExpressions;
namespace Nexus.Api.Services;
public sealed partial class IncidentService : IIncidentService
{
private const string BasePath = "/mnt/workspace-iris/memory/incidents";
public async Task<IReadOnlyList<IncidentSummary>> GetAllAsync()
{
if (!Directory.Exists(BasePath))
return Array.Empty<IncidentSummary>();
var incidents = new List<IncidentSummary>();
foreach (var file in Directory.GetFiles(BasePath, "*.md").OrderByDescending(f => f).Take(50))
{
var fi = new FileInfo(file);
if (fi.Length > 1_000_000) continue;
var name = Path.GetFileNameWithoutExtension(file);
var content = await File.ReadAllTextAsync(file);
var title = ExtractTitle(name, content);
var date = ExtractDate(name);
var severity = ExtractSeverity(content);
var excerpt = ExtractExcerpt(content);
incidents.Add(new IncidentSummary(Path.GetFileName(file), title, date, severity, excerpt, fi.Length));
}
return incidents;
}
public async Task<IncidentDetail?> GetByNameAsync(string name)
{
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out var filePath))
return null;
if (!File.Exists(filePath!))
{
if (!name.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
filePath = Path.Combine(BasePath, name + ".md");
if (!File.Exists(filePath!))
return null;
}
var content = await File.ReadAllTextAsync(filePath!);
var fi = new FileInfo(filePath!);
var fileName = Path.GetFileName(filePath!);
var title = ExtractTitle(Path.GetFileNameWithoutExtension(filePath!), content);
var date = ExtractDate(fileName);
return new IncidentDetail(fileName, title, date, content, fi.Length);
}
private static string ExtractTitle(string name, string content)
{
var match = TitleRegex().Match(content);
return match.Success ? match.Groups[1].Value.Trim() : name;
}
private static string? ExtractDate(string fileName)
{
var match = DateRegex().Match(fileName);
return match.Success ? match.Groups[1].Value : null;
}
private static string ExtractSeverity(string content)
{
var match = SeverityRegex().Match(content);
return match.Success ? match.Groups[1].Value.Trim() : "unknown";
}
private static string ExtractExcerpt(string content)
{
var excerptEnd = content.IndexOf("\n## ", StringComparison.Ordinal);
var excerpt = excerptEnd > 0 ? content[..excerptEnd].Trim() : content[..Math.Min(300, content.Length)].Trim();
return excerpt.Length > 200 ? excerpt[..200] + "…" : excerpt;
}
[GeneratedRegex(@"^#\s+(.+)$", RegexOptions.Multiline)]
private static partial Regex TitleRegex();
[GeneratedRegex(@"^(\d{4}-\d{2}-\d{2})")]
private static partial Regex DateRegex();
[GeneratedRegex(@"\*\*Severity:\*\*\s*(.+)$", RegexOptions.Multiline)]
private static partial Regex SeverityRegex();
}
+100
View File
@@ -0,0 +1,100 @@
using Nexus.Api.Helpers;
namespace Nexus.Api.Services;
public sealed class MemoryService : IMemoryService
{
private const string BasePath = "/mnt/workspace-iris/memory";
private const string LongTermPath = "/mnt/workspace-iris/MEMORY.md";
private const int MaxFileSize = 1_000_000;
private const int MaxFiles = 50;
public Task<IReadOnlyList<MemoryFileInfo>> GetAllAsync()
{
var files = new List<MemoryFileInfo>();
if (File.Exists(LongTermPath))
{
var fi = new FileInfo(LongTermPath);
files.Add(new MemoryFileInfo("MEMORY.md", "MEMORY.md", fi.Length, fi.LastWriteTimeUtc));
}
if (Directory.Exists(BasePath))
{
var memFiles = Directory.GetFiles(BasePath, "*.md")
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.Name)
.Select(f => new MemoryFileInfo(
f.Name,
f.FullName.Replace(BasePath, "").TrimStart('/'),
f.Length,
f.LastWriteTimeUtc));
files.AddRange(memFiles);
}
return Task.FromResult<IReadOnlyList<MemoryFileInfo>>(files);
}
public async Task<IReadOnlyList<MemorySearchResult>> SearchAsync(string query)
{
var results = new List<MemorySearchResult>();
async Task SearchDir(string dir)
{
if (!Directory.Exists(dir)) return;
foreach (var file in Directory.GetFiles(dir, "*.md").Take(MaxFiles))
{
var fi = new FileInfo(file);
if (fi.Length > MaxFileSize) continue;
var content = await File.ReadAllTextAsync(file);
if (!content.Contains(query, StringComparison.OrdinalIgnoreCase)) continue;
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
results.Add(new MemorySearchResult(
Path.GetFileName(file),
file.Replace(BasePath, "").TrimStart('/'),
excerpt,
fi.Length));
}
}
await SearchDir(BasePath);
if (File.Exists(LongTermPath))
{
var content = await File.ReadAllTextAsync(LongTermPath);
if (content.Contains(query, StringComparison.OrdinalIgnoreCase))
{
var idx = content.IndexOf(query, StringComparison.OrdinalIgnoreCase);
var start = Math.Max(0, idx - 60);
var excerpt = (start > 0 ? "…" : "") + content.Substring(start, Math.Min(200, content.Length - start)) + "…";
results.Insert(0, new MemorySearchResult("MEMORY.md", "MEMORY.md", excerpt, content.Length));
}
}
return results;
}
public async Task<MemoryFileContent?> GetFileAsync(string name)
{
string? filePath;
if (name.Equals("MEMORY.md", StringComparison.OrdinalIgnoreCase))
{
filePath = LongTermPath;
}
else
{
if (!PathSecurityHelper.TryResolveSafePath(BasePath, name, out filePath))
return null;
}
if (!File.Exists(filePath!))
return null;
var content = await File.ReadAllTextAsync(filePath!);
return new MemoryFileContent(name, name, content, content.Length, File.GetLastWriteTimeUtc(filePath!));
}
}
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
using Nexus.Api.Data;
using Nexus.Api.Integrations;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class OperationsService(
IAgentRuntime runtime,
IAgentService agentService,
IProjectRepository projectRepo,
ITaskRepository taskRepo,
IActivityRepository activityRepo) : IOperationsService
{
public async Task<object> GetSnapshotAsync(CancellationToken ct = default)
{
var runtimeTask = runtime.GetStatusAsync(ct);
var agentsTask = agentService.GetAgentsAsync(ct);
// Repository calls share the scoped EF Core DbContext and must stay serialized.
var projects = await projectRepo.GetAllAsync(ct);
var tasks = await taskRepo.GetAllAsync(ct);
var activity = await activityRepo.GetRecentAsync(20, ct);
var agents = await agentsTask;
var completedTasks = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Done));
var runtimeStatus = await runtimeTask;
var lastIncident = tasks
.Where(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
.OrderByDescending(x => x.UpdatedAt)
.Select(x => new { TaskId = (Guid?)x.Id, Title = (string?)x.Title, Since = (DateTimeOffset?)x.UpdatedAt })
.FirstOrDefault();
return new
{
generatedAt = DateTimeOffset.UtcNow,
runtime = runtimeStatus,
models = Array.Empty<object>(),
runtimeHealthy = runtimeStatus.Status == OperationalStatus.Online,
metrics = new
{
activeAgents = agents.Count,
queuedTasks = tasks.Count - completedTasks,
successRate = tasks.Count == 0 ? 100 : Math.Round(completedTasks * 100d / tasks.Count, 1),
incidents = tasks.Count(x => x.State == TaskStateHelper.ToStateString(TaskState.Blocked))
},
lastIncident,
projectHealth = new
{
Online = projects.Count(x => x.Status == OperationalStatus.Online),
Offline = projects.Count(x => x.Status == OperationalStatus.Offline),
Degraded = projects.Count(x => x.Status == OperationalStatus.Degraded),
Unknown = projects.Count(x => x.Status == OperationalStatus.Unknown)
},
agents = agents.Select(x => new { x.Id, x.Name, x.Role, x.Status, x.Model }),
projects = projects.Select(x => new { x.Id, x.Name, x.Status, x.Progress, x.UpdatedAt }),
tasks = tasks.Select(x => new { x.Id, x.Title, x.State, x.Priority, x.ProjectId, x.UpdatedAt }),
activity = activity.Select(x => new { x.Id, x.Type, x.Message, at = x.CreatedAt })
};
}
}
+64
View File
@@ -0,0 +1,64 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class ProjectService(
IProjectRepository projectRepo,
IActivityRepository activityRepo) : IProjectService
{
public async Task<IReadOnlyList<Project>> GetAllAsync(CancellationToken ct = default)
=> await projectRepo.GetAllAsync(ct);
public async Task<Project?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await projectRepo.GetByIdAsync(id, ct);
public async Task<Project> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{
var project = new Project
{
Name = request.Name.Trim(),
Description = request.Description?.Trim() ?? string.Empty,
Status = OperationalStatus.Online
};
await projectRepo.AddAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} created" }, ct);
return project;
}
public async Task<Project?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return null;
if (!string.IsNullOrWhiteSpace(request.Name))
project.Name = request.Name.Trim();
if (request.Description is not null)
project.Description = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Status) && Enum.TryParse<OperationalStatus>(request.Status, true, out var parsedStatus))
project.Status = parsedStatus;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} updated" }, ct);
return project;
}
public async Task<ProjectDeleteResult> DeleteAsync(Guid id, CancellationToken ct = default)
{
var project = await projectRepo.GetByIdAsync(id, ct);
if (project is null) return new ProjectDeleteResult(ProjectDeleteOutcome.NotFound);
if (await projectRepo.HasTasksAsync(id, ct))
{
project.Status = OperationalStatus.Offline;
await projectRepo.UpdateAsync(project, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} archived" }, ct);
return new ProjectDeleteResult(ProjectDeleteOutcome.Archived, project);
}
await activityRepo.AddAsync(new ActivityEvent { Type = "project", Message = $"Project {project.Name} deleted" }, ct);
await projectRepo.DeleteAsync(project, ct);
return new ProjectDeleteResult(ProjectDeleteOutcome.Deleted);
}
}
+191
View File
@@ -0,0 +1,191 @@
using Nexus.Api.Data;
using Nexus.Api.DTOs;
using Nexus.Api.Repositories;
namespace Nexus.Api.Services;
public sealed class TaskService(
ITaskRepository taskRepo,
IActivityRepository activityRepo) : ITaskService
{
public async Task<IReadOnlyList<WorkTask>> GetAllAsync(CancellationToken ct = default)
=> await taskRepo.GetAllAsync(ct);
public async Task<WorkTask?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> await taskRepo.GetByIdAsync(id, ct);
public async Task<IReadOnlyList<WorkTask>> GetPendingApprovalAsync(CancellationToken ct = default)
=> await taskRepo.GetPendingApprovalAsync(ct);
public async Task<WorkTask> CreateAsync(CreateTaskRequest request, CancellationToken ct = default)
{
var task = new WorkTask
{
Title = request.Title.Trim(),
Priority = string.IsNullOrWhiteSpace(request.Priority) ? "Normal" : request.Priority.Trim(),
ProjectId = request.ProjectId
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} created" }, ct);
return task;
}
public async Task<TaskOperationResult> ApproveAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Done);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} approved" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> RejectAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsInProgressOrBlocked(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
task.State = TaskStateHelper.ToStateString(TaskState.Backlog);
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} rejected, returned to backlog" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStateAsync(Guid id, string state, CancellationToken ct = default)
{
var canonical = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(state, StringComparison.OrdinalIgnoreCase));
if (canonical is null) return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} moved to {task.State}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateAsync(Guid id, UpdateTaskRequest request, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.IsNullOrWhiteSpace(request.Title))
task.Title = request.Title.Trim();
if (!string.IsNullOrWhiteSpace(request.Priority))
task.Priority = request.Priority.Trim();
if (request.ProjectId.HasValue)
task.ProjectId = request.ProjectId.Value == Guid.Empty ? null : request.ProjectId;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} updated" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> DeleteAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!TaskStateHelper.IsDoneOrBacklog(task.State))
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task {task.Title} deleted" }, ct);
await taskRepo.DeleteAsync(task, ct);
return new TaskOperationResult(TaskOperationOutcome.Success);
}
// ── Dashboard-facing operations ──
public async Task<IReadOnlyList<WorkTask>> GetOpenAsync(CancellationToken ct = default)
{
var all = await taskRepo.GetAllAsync(ct);
return all.Where(t => !string.Equals(t.State, "Done", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(t => t.CreatedAt)
.ToList();
}
public async Task<WorkTask> CreateDashboardTaskAsync(
string title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
{
var task = new WorkTask
{
Title = title.Trim(),
Detail = detail?.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(),
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = assignedTo?.Trim()
};
await taskRepo.AddAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" created ({task.Source})" }, ct);
return task;
}
public async Task<TaskOperationResult> UpdateDashboardTaskAsync(
Guid id, string? title, string? detail, string? source, string? priority, string? assignedTo, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
if (!string.IsNullOrWhiteSpace(title)) task.Title = title.Trim();
if (detail is not null) task.Detail = string.IsNullOrWhiteSpace(detail) ? null : detail.Trim();
if (!string.IsNullOrWhiteSpace(source)) task.Source = source.Trim();
if (!string.IsNullOrWhiteSpace(priority)) task.Priority = priority.Trim();
if (assignedTo is not null) task.AssignedTo = string.IsNullOrWhiteSpace(assignedTo) ? null : assignedTo.Trim();
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" updated" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> UpdateStatusAsync(Guid id, string status, CancellationToken ct = default)
{
if (!TaskStateHelper.IsValidState(status))
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
var canonical = TaskStateHelper.AllStates.First(s => s.Equals(status, StringComparison.OrdinalIgnoreCase));
task.State = canonical;
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" → {canonical}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> CompleteViaQueueAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.State = "Done";
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" completed via queue" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
public async Task<TaskOperationResult> CyclePriorityAsync(Guid id, CancellationToken ct = default)
{
var task = await taskRepo.GetByIdAsync(id, ct);
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
task.Priority = task.Priority.ToLowerInvariant() switch
{
"high" => "Medium",
"medium" => "Low",
"low" => "High",
_ => "Medium"
};
await taskRepo.UpdateAsync(task, ct);
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = $"Task \"{task.Title}\" priority → {task.Priority}" }, ct);
return new TaskOperationResult(TaskOperationOutcome.Success, task);
}
}
+34
View File
@@ -0,0 +1,34 @@
namespace Nexus.Api.Services;
public sealed class TeamService(IAgentService agentService) : ITeamService
{
public async Task<IReadOnlyList<TeamMember>> GetTeamAsync(CancellationToken ct = default)
{
var agents = await agentService.GetAgentsAsync(ct);
var team = new List<TeamMember>(agents.Count);
foreach (var agent in agents)
{
var identity = await ReadIdentityAsync(agent.Workspace, ct);
team.Add(new TeamMember(
agent.Id, agent.Name, agent.Role, agent.Model,
agent.Status, agent.LastSeen, agent.Workspace, agent.Description,
identity));
}
return team;
}
private static async Task<string> ReadIdentityAsync(string? workspace, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(workspace) || !Directory.Exists(workspace))
return string.Empty;
var identityFile = Path.Combine(workspace, "IDENTITY.md");
if (!File.Exists(identityFile))
return string.Empty;
var content = await File.ReadAllTextAsync(identityFile, ct);
return string.Join("\n", content.Split('\n').Where(l => l.StartsWith("- **")).Take(8));
}
}
+66 -2
View File
@@ -4,6 +4,12 @@ services:
postgres: postgres:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 256M
reservations:
memory: 64M
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-nexus} POSTGRES_DB: ${POSTGRES_DB:-nexus}
POSTGRES_USER: ${POSTGRES_USER:-nexus} POSTGRES_USER: ${POSTGRES_USER:-nexus}
@@ -15,12 +21,29 @@ services:
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 30s
networks: [nexus] networks: [nexus]
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
api: api:
build: build:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
environment: environment:
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080 ASPNETCORE_URLS: http://+:8080
@@ -34,31 +57,72 @@ services:
Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789} Integrations__OpenClaw__BaseUrl: ${OPENCLAW_BASE_URL:-http://host.docker.internal:18789}
Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-} Integrations__OpenClaw__Token: ${OPENCLAW_GATEWAY_TOKEN:-}
Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-} Integrations__OpenClaw__Password: ${OPENCLAW_GATEWAY_PASSWORD:-}
Admin__ResetToken: ${Admin__ResetToken:-}
extra_hosts: extra_hosts:
- host.docker.internal:host-gateway - host.docker.internal:host-gateway
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
volumes: volumes:
- /opt/openclaw/data/openclaw/openclaw.json:/home/node/.openclaw/openclaw.json:ro
- /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris - /opt/openclaw/data/openclaw/workspace-iris:/mnt/workspace-iris
- /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer - /opt/openclaw/data/openclaw/workspace-programmer:/mnt/workspace-programmer
- /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer - /opt/openclaw/data/openclaw/workspace-reviewer:/mnt/workspace-reviewer
- /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt - /opt/openclaw/data/openclaw/workspace-architekt:/mnt/workspace-architekt
- /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher - /opt/openclaw/data/openclaw/workspace-researcher:/mnt/workspace-researcher
- /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor - /opt/openclaw/data/openclaw/workspace-executor:/mnt/workspace-executor
networks: [nexus] networks:
- nexus
- openclaw_default
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
web: web:
build: build:
context: ./frontend context: ./frontend
restart: unless-stopped restart: unless-stopped
deploy:
resources:
limits:
memory: 128M
reservations:
memory: 32M
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
ports: ports:
- "127.0.0.1:18880:80" - "127.0.0.1:18880:80"
depends_on: [api] depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:80/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks: [nexus] networks: [nexus]
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks: networks:
nexus: nexus:
openclaw_default:
external: true
volumes: volumes:
nexus-postgres: 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
+3
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#080a0f" /> <meta name="theme-color" content="#080a0f" />
<title>Nexus | Noveria Operations</title> <title>Nexus | Noveria Operations</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+13
View File
@@ -11,6 +11,19 @@ server {
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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/ { location /api/ {
proxy_pass http://api:8080; proxy_pass http://api:8080;
proxy_set_header Host $host; proxy_set_header Host $host;
+6 -2
View File
@@ -10,10 +10,15 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.8",
"@lucide/vue": "1.17.0", "@lucide/vue": "1.17.0",
"@tailwindcss/vite": "^4.1.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.8", "tailwindcss": "^4.1.8",
"tailwindcss-animate": "^1.0.7",
"vue": "^3.5.16", "vue": "^3.5.16",
"vue-router": "^4.5.1" "vue-router": "^4.5.1"
}, },
@@ -27,4 +32,3 @@
}, },
"packageManager": "pnpm@10.12.1" "packageManager": "pnpm@10.12.1"
} }
+1987
View File
File diff suppressed because it is too large Load Diff
+10 -30
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from './stores/auth'
import AppSidebar from './components/layout/AppSidebar.vue' import AppSidebar from './components/layout/AppSidebar.vue'
import AppHeader from './components/layout/AppHeader.vue' import AppHeader from './components/layout/AppHeader.vue'
import ModuleView from './components/ModuleView.vue' import ModuleView from './components/ModuleView.vue'
import ToastContainer from './components/ui/ToastContainer.vue'
const store = useOperationsStore() const store = useOperationsStore()
const auth = useAuthStore() const auth = useAuthStore()
@@ -20,7 +21,7 @@ const activeView = computed(() => {
}) })
const routePaths: Record<string, string> = { const routePaths: Record<string, string> = {
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Team: '/team', Security: '/security', Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar', Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings', Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Settings: '/settings',
} }
@@ -31,7 +32,7 @@ const navigate = (label: string) => {
} }
const mobileNavOpen = ref(false) const mobileNavOpen = ref(false)
const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Team', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value)) const standaloneViews = computed(() => ['Dashboard', 'Settings', 'ProjectDetail', 'Memory', 'Docs', 'Security', 'Incidents', 'Calendar', 'AgentDetail', 'Agents'].includes(activeView.value))
onMounted(() => { onMounted(() => {
if (auth.isAuthenticated) store.refresh() if (auth.isAuthenticated) store.refresh()
@@ -39,7 +40,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<RouterView v-if="route.name === 'Login'" /> <RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
<div v-else class="shell"> <div v-else class="shell">
<AppSidebar <AppSidebar
:active-view="activeView" :active-view="activeView"
@@ -82,32 +83,11 @@ onMounted(() => {
</template> </template>
</section> </section>
</main> </main>
<ToastContainer />
</div> </div>
</template> </template>
<style scoped> <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 { .shell {
display: flex; display: flex;
height: 100vh; height: 100vh;
@@ -135,12 +115,12 @@ main {
gap: 12px; gap: 12px;
} }
.page-heading h1 { margin: 0; font-size: 18px; } .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 { .eyebrow {
font-size: 8.5px; font-size: 8.5px;
font-weight: 700; font-weight: 700;
letter-spacing: .12em; letter-spacing: .12em;
color: var(--accent); color: var(--nx-accent);
text-transform: uppercase; text-transform: uppercase;
} }
.refresh { .refresh {
@@ -149,15 +129,15 @@ main {
gap: 5px; gap: 5px;
flex-shrink: 0; flex-shrink: 0;
padding: 6px 11px; padding: 6px 11px;
border: 1px solid var(--line); border: 1px solid var(--nx-line);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: var(--text-dim); color: var(--nx-text-dim);
font-size: 9px; font-size: 9px;
cursor: pointer; cursor: pointer;
transition: background .15s; 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; } .spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
+727
View File
@@ -0,0 +1,727 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
/* ── Nexus V2 Theme (Tailwind v4 @theme directive) ── */
@theme {
/* Font families */
--font-sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-display: 'Space Grotesk', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
/* Space surfaces */
--color-space-0: #050410;
--color-space-1: #0a0818;
--color-space-2: #0e0c20;
--color-space-3: #141130;
--color-space-4: #1b1742;
/* Glass */
--color-glass: rgba(20, 17, 48, 0.55);
--color-glass-2: rgba(28, 24, 64, 0.55);
/* Lines */
--color-line: rgba(150, 140, 255, 0.10);
--color-line-2: rgba(150, 140, 255, 0.18);
--color-line-3: rgba(150, 140, 255, 0.30);
/* Text */
--color-tx: #ece9ff;
--color-tx-2: #a8a3d6;
--color-tx-3: #6f6aa0;
/* Accent */
--color-a-blue: #4f7cff;
--color-a-purple: #b557f6;
--color-a-mid: #7c6cff;
/* Status */
--color-st-work: #3ddc97;
--color-st-think: #34d6f5;
--color-st-queue: #fbbf24;
--color-st-block: #fb7185;
--color-st-idle: #6b6796;
/* Border radius */
--radius-r: 14px;
--radius-r-sm: 10px;
--radius-r-lg: 20px;
}
: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: 'Manrope', 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;
}
}
+110
View File
@@ -0,0 +1,110 @@
/* ================================================================
nexus-tokens.css — Nexus Mission Control V2 Design Tokens
Geladen NACH main.css, überschreibt shadcn/v1-Standards.
================================================================ */
:root {
/* ── Surfaces ─────────────────────────────────────── */
--space-0: #050410;
--space-1: #0a0818;
--space-2: #0e0c20;
--space-3: #141130;
--space-4: #1b1742;
--glass: rgba(20, 17, 48, 0.55);
--glass-2: rgba(28, 24, 64, 0.55);
/* ── Lines ────────────────────────────────────────── */
--line: rgba(150, 140, 255, 0.10);
--line-2: rgba(150, 140, 255, 0.18);
--line-3: rgba(150, 140, 255, 0.30);
/* ── Text ─────────────────────────────────────────── */
--tx: #ece9ff;
--tx-2: #a8a3d6;
--tx-3: #6f6aa0;
/* ── Accent Gradient ──────────────────────────────── */
--a-blue: #4f7cff;
--a-purple: #b557f6;
--a-mid: #7c6cff;
--grad: linear-gradient(120deg, var(--a-blue), var(--a-purple));
--grad-soft: linear-gradient(120deg, rgba(79,124,255,.18), rgba(181,87,246,.18));
/* ── Status ───────────────────────────────────────── */
--st-work: #3ddc97;
--st-think: #34d6f5;
--st-queue: #fbbf24;
--st-block: #fb7185;
--st-idle: #6b6796;
/* ── Glows ────────────────────────────────────────── */
--glow: 0 0 0 1px rgba(124,108,255,.20), 0 0 28px -4px rgba(124,108,255,.55);
--glow-blue: 0 0 24px -2px rgba(79,124,255,.65);
--glow-purple: 0 0 24px -2px rgba(181,87,246,.60);
--glow-work: 0 0 16px -1px rgba(61,220,151,.70);
--glow-think: 0 0 16px -1px rgba(52,214,245,.65);
/* ── Radius ───────────────────────────────────────── */
--r: 14px;
--r-sm: 10px;
--r-lg: 20px;
/* ── Layout ───────────────────────────────────────── */
--sidebar-w: 248px;
--topbar-h: 62px;
--rail-w: 360px;
}
/* ── Glass card utility ────────────────────────────── */
.glass-panel {
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--r);
backdrop-filter: blur(12px);
}
/* ── Gradient text ─────────────────────────────────── */
.grad-text {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* ── Status dot keyframes ──────────────────────────── */
@keyframes pulse-work {
0% { box-shadow: 0 0 0 0 rgba(61,220,151,.55); }
70% { box-shadow: 0 0 0 7px rgba(61,220,151,0); }
100% { box-shadow: 0 0 0 0 rgba(61,220,151,0); }
}
@keyframes pulse-think {
0% { box-shadow: 0 0 0 0 rgba(52,214,245,.55); }
70% { box-shadow: 0 0 0 7px rgba(52,214,245,0); }
100% { box-shadow: 0 0 0 0 rgba(52,214,245,0); }
}
@keyframes pulse-block {
0% { box-shadow: 0 0 0 0 rgba(251,113,133,.55); }
70% { box-shadow: 0 0 0 7px rgba(251,113,133,0); }
100% { box-shadow: 0 0 0 0 rgba(251,113,133,0); }
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
/* ── V2 Scrollbars ─────────────────────────────────── */
.v2-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
.v2-scroll::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 9px;
border: 2px solid transparent;
background-clip: padding-box;
}
.v2-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(124,108,255,.4);
background-clip: padding-box;
}
.v2-scroll::-webkit-scrollbar-track { background: transparent; }
/* ── Typography helpers ────────────────────────────── */
.font-display { font-family: 'Space Grotesk', sans-serif; }
.font-mono-v2 { font-family: 'JetBrains Mono', monospace; font-variant-numeric: tabular-nums; }
+3 -3
View File
@@ -361,7 +361,7 @@ async function sendMessage() {
opacity: 1; opacity: 1;
} }
.task-edit-btn:hover { .task-edit-btn:hover {
color: var(--accent); color: var(--nx-accent);
} }
.task-delete-btn { .task-delete-btn {
background: none; background: none;
@@ -411,7 +411,7 @@ async function sendMessage() {
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: var(--accent); background: var(--nx-accent);
color: #fff; color: #fff;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
@@ -499,7 +499,7 @@ async function sendMessage() {
color: var(--text-secondary); color: var(--text-secondary);
} }
.settings-redirect a { .settings-redirect a {
color: var(--accent); color: var(--nx-accent);
text-decoration: none; text-decoration: none;
} }
.settings-redirect a:hover { .settings-redirect a:hover {
@@ -0,0 +1,249 @@
<script setup lang="ts">
/**
* GalaxyBackground Canvas Starfield + Aurora Blobs
*
* Ported from assets/galaxy.js (design_handoff_nexus_v2).
* Auto-mounts onMounted; cleans up onUnmounted.
* Fixed overlay with z-index:0 and pointer-events:none.
*/
import { onMounted, onUnmounted, ref } from 'vue'
const rootRef = ref<HTMLElement | null>(null)
interface Star {
x: number
y: number
r: number
a: number
tw: number
ph: number
hue: number
dx: number
dy: number
}
interface Shoot {
x: number
y: number
len: number
sp: number
ang: number
life: number
}
let resizeObserver: ResizeObserver | null = null
let animFrameId = 0
let canvas: HTMLCanvasElement | null = null
let ctx: CanvasRenderingContext2D | null = null
function mount(root: HTMLElement) {
canvas = document.createElement('canvas')
root.appendChild(canvas)
ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = Math.min(window.devicePixelRatio || 1, 2)
let stars: Star[] = []
let shoots: Shoot[] = []
let W = 0
let H = 0
let t = 0
function resize() {
const r = root.getBoundingClientRect()
W = r.width
H = r.height
canvas!.width = W * dpr
canvas!.height = H * dpr
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
const count = Math.round((W * H) / 5200)
stars = []
for (let i = 0; i < count; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
r: Math.random() * 1.3 + 0.25,
a: Math.random() * 0.6 + 0.2,
tw: Math.random() * 0.025 + 0.004,
ph: Math.random() * Math.PI * 2,
hue: Math.random() < 0.5 ? 230 : 270,
dx: (Math.random() - 0.5) * 0.04,
dy: (Math.random() - 0.5) * 0.04,
})
}
}
function spawnShoot() {
const fromLeft = Math.random() < 0.6
shoots.push({
x: fromLeft ? -40 : W * (0.4 + Math.random() * 0.5),
y: Math.random() * H * 0.5,
len: 90 + Math.random() * 120,
sp: 6 + Math.random() * 5,
ang: Math.random() * 0.3 + 0.15,
life: 1,
})
}
function frame() {
ctx!.clearRect(0, 0, W, H)
t += 1
// Draw stars
for (let i = 0; i < stars.length; i++) {
const s = stars[i]
s.ph += s.tw
const a = s.a * (0.55 + 0.45 * Math.sin(s.ph))
s.x += s.dx
s.y += s.dy
if (s.x < 0) s.x = W
if (s.x > W) s.x = 0
if (s.y < 0) s.y = H
if (s.y > H) s.y = 0
ctx!.beginPath()
ctx!.fillStyle = `hsla(${s.hue},90%,82%,${a})`
ctx!.arc(s.x, s.y, s.r, 0, Math.PI * 2)
ctx!.fill()
// Glow for larger stars
if (s.r > 1) {
ctx!.beginPath()
ctx!.fillStyle = `hsla(${s.hue},95%,80%,${a * 0.12})`
ctx!.arc(s.x, s.y, s.r * 3.5, 0, Math.PI * 2)
ctx!.fill()
}
}
// Shooting stars
if (Math.random() < 0.004 && shoots.length < 2) spawnShoot()
for (let j = shoots.length - 1; j >= 0; j--) {
const sh = shoots[j]
sh.x += Math.cos(sh.ang) * sh.sp
sh.y += Math.sin(sh.ang) * sh.sp
sh.life -= 0.006
const ex = sh.x - Math.cos(sh.ang) * sh.len
const ey = sh.y - Math.sin(sh.ang) * sh.len
const g = ctx!.createLinearGradient(sh.x, sh.y, ex, ey)
g.addColorStop(0, `rgba(200,210,255,${0.9 * sh.life})`)
g.addColorStop(1, 'rgba(160,120,255,0)')
ctx!.strokeStyle = g
ctx!.lineWidth = 2
ctx!.lineCap = 'round'
ctx!.beginPath()
ctx!.moveTo(sh.x, sh.y)
ctx!.lineTo(ex, ey)
ctx!.stroke()
if (sh.life <= 0 || sh.x > W + 60 || sh.y > H + 60) shoots.splice(j, 1)
}
animFrameId = requestAnimationFrame(frame)
}
resizeObserver = new ResizeObserver(() => resize())
resizeObserver.observe(root)
resize()
frame()
}
function cleanup() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (animFrameId) {
cancelAnimationFrame(animFrameId)
animFrameId = 0
}
if (canvas && canvas.parentNode) {
canvas.parentNode.removeChild(canvas)
}
canvas = null
ctx = null
}
onMounted(() => {
if (rootRef.value) mount(rootRef.value)
})
onUnmounted(() => {
cleanup()
})
</script>
<template>
<div ref="rootRef" class="galaxy-bg">
<div class="aurora a1"></div>
<div class="aurora a2"></div>
<div class="aurora a3"></div>
</div>
</template>
<style scoped>
.galaxy-bg {
position: fixed;
inset: 0;
z-index: 0;
overflow: hidden;
background:
radial-gradient(1200px 800px at 18% -8%, rgba(79,124,255,.20), transparent 60%),
radial-gradient(1000px 760px at 92% 8%, rgba(181,87,246,.18), transparent 60%),
radial-gradient(900px 700px at 60% 110%, rgba(70,60,180,.20), transparent 60%),
linear-gradient(180deg, #070512, #0a0818 60%, #060410);
pointer-events: none;
}
.galaxy-bg canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.aurora {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.5;
mix-blend-mode: screen;
will-change: transform;
}
.a1 {
width: 46vw;
height: 46vw;
left: -8vw;
top: -14vw;
background: radial-gradient(circle, rgba(79,124,255,.55), transparent 65%);
animation: drift1 26s ease-in-out infinite;
}
.a2 {
width: 40vw;
height: 40vw;
right: -10vw;
top: 2vw;
background: radial-gradient(circle, rgba(181,87,246,.5), transparent 65%);
animation: drift2 32s ease-in-out infinite;
}
.a3 {
width: 42vw;
height: 42vw;
left: 34vw;
bottom: -18vw;
background: radial-gradient(circle, rgba(95,80,220,.45), transparent 65%);
animation: drift3 30s ease-in-out infinite;
}
@keyframes drift1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(8vw, 6vw) scale(1.12); }
}
@keyframes drift2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-7vw, 5vw) scale(1.1); }
}
@keyframes drift3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(4vw, -6vw) scale(1.15); }
}
</style>
@@ -1,201 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Clock } from '@lucide/vue'
type InitiativeStatus = 'healthy' | 'attention' | 'blocked' | 'paused' | 'completed'
interface Initiative {
title: string
progress: number
openTasks: number
blockers: number
status: InitiativeStatus
lastActivity: string
}
const initiatives = ref<Initiative[]>([
{ title: 'OpenClaw Companion', progress: 55, openTasks: 7, blockers: 2, status: 'healthy', lastActivity: 'vor 8 Minuten' },
{ title: '2D Idle Game', progress: 42, openTasks: 4, blockers: 0, status: 'healthy', lastActivity: 'vor 2 Stunden' },
{ title: 'Deutsch B2', progress: 73, openTasks: 3, blockers: 0, status: 'attention', lastActivity: 'vor 1 Stunde' },
{ title: 'Nexus Dashboard', progress: 60, openTasks: 3, blockers: 0, status: 'healthy', lastActivity: 'vor 5 Minuten' },
])
const statusMeta: Record<InitiativeStatus, { label: string; color: string; bg: string }> = {
healthy: { label: 'Healthy', color: '#22c55e', bg: 'rgba(34,197,94,0.1)' },
attention: { label: 'Needs Attention', color: '#eab308', bg: 'rgba(234,179,8,0.1)' },
blocked: { label: 'Blocked', color: '#ef4444', bg: 'rgba(239,68,68,0.1)' },
paused: { label: 'Paused', color: '#6b7280', bg: 'rgba(107,114,128,0.1)' },
completed: { label: 'Completed', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
}
function onInitiativeClick(title: string) {
console.log('[Dashboard] Open initiative:', title)
}
</script>
<template>
<div class="initiatives-section">
<h2>Active Initiatives</h2>
<div class="initiatives-grid">
<div
v-for="(init, idx) in initiatives"
:key="idx"
:class="['initiative-card', 'status-' + init.status]"
@click="onInitiativeClick(init.title)"
role="button"
tabindex="0"
@keyup.enter="onInitiativeClick(init.title)"
>
<div class="init-head">
<h3>{{ init.title }}</h3>
<span
class="status-badge"
:style="{
color: statusMeta[init.status].color,
background: statusMeta[init.status].bg,
}"
>
{{ statusMeta[init.status].label }}
</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{ width: init.progress + '%' }"
></div>
</div>
<div class="progress-label">{{ init.progress }}%</div>
<div class="init-stats">
<span>{{ init.openTasks }} offene Aufgaben</span>
<span v-if="init.blockers">&middot; {{ init.blockers }} Blocker</span>
</div>
<div class="init-meta">
<Clock :size="11" />
<span>Letzte Aktivität {{ init.lastActivity }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.initiatives-section {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.initiatives-section:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.initiatives-section h2 {
font-size: 14px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.initiatives-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.initiative-card {
background: rgba(13, 17, 23, 0.5);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 14px;
padding: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.initiative-card:hover {
transform: scale(1.02);
border-color: rgba(139, 124, 246, 0.2);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.initiative-card:focus-visible {
outline: 2px solid #a78bfa;
outline-offset: 2px;
}
.init-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
gap: 8px;
}
.init-head h3 {
font-size: 12px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.status-badge {
font-size: 8px;
font-weight: 700;
padding: 2px 7px;
border-radius: 12px;
white-space: nowrap;
flex-shrink: 0;
letter-spacing: 0.02em;
}
.progress-bar {
height: 4px;
background: rgba(139, 124, 246, 0.1);
border-radius: 4px;
margin-bottom: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, #a78bfa, #8b5cf6);
transition: width 0.5s ease;
}
.initiative-card.status-attention .progress-fill {
background: linear-gradient(90deg, #eab308, #f59e0b);
}
.initiative-card.status-blocked .progress-fill {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
.progress-label {
font-size: 10px;
color: #6b7385;
margin-bottom: 4px;
}
.init-stats {
font-size: 9px;
color: #6b7385;
margin-bottom: 4px;
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.init-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
color: #6b7385;
}
.init-meta svg {
flex-shrink: 0;
}
@media (max-width: 900px) {
.initiatives-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 600px) {
.initiatives-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,229 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { CheckCircle2, Circle, AlertTriangle } from '@lucide/vue'
interface AgendaItem {
text: string
time?: string
done?: boolean
overdue?: boolean
}
const STORAGE_KEY = 'nexus-agenda-done'
const agendaToday = ref<AgendaItem[]>([
{ text: 'Teammeeting', time: '14:00' },
{ text: 'Deutsch lernen', time: '18:00' },
{ text: 'Steuerunterlagen prüfen' },
{ text: 'Dungeon-Balance abschließen' },
])
const agendaTomorrow = ref<AgendaItem[]>([
{ text: 'GitHub Issue #23' },
{ text: 'Backup überprüfen' },
])
const agendaOverdue = ref<AgendaItem[]>([
{ text: 'Hangfire konfigurieren', overdue: true },
])
function loadDoneStates() {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return
const keys: string[] = JSON.parse(raw)
const set = new Set(keys)
const sections = [
{ items: agendaToday.value, prefix: 'today' },
{ items: agendaTomorrow.value, prefix: 'tomorrow' },
{ items: agendaOverdue.value, prefix: 'overdue' },
]
for (const { items, prefix } of sections) {
items.forEach((item, i) => {
if (set.has(`${prefix}-${i}`)) item.done = true
})
}
} catch { /* ignore malformed storage */ }
}
function saveDoneStates() {
const keys: string[] = []
const sections = [
{ items: agendaToday.value, prefix: 'today' },
{ items: agendaTomorrow.value, prefix: 'tomorrow' },
{ items: agendaOverdue.value, prefix: 'overdue' },
]
for (const { items, prefix } of sections) {
items.forEach((item, i) => {
if (item.done) keys.push(`${prefix}-${i}`)
})
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys))
}
function toggleAgendaItem(item: AgendaItem) {
item.done = !item.done
saveDoneStates()
}
onMounted(() => {
loadDoneStates()
})
</script>
<template>
<div class="agenda-panel">
<h2>Agenda</h2>
<div class="agenda-section">
<h3>Heute</h3>
<div
v-for="(item, idx) in agendaToday"
:key="'today-' + idx"
:class="['agenda-item', { done: item.done }]"
@click="toggleAgendaItem(item)"
>
<button class="agenda-check">
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
<Circle v-else :size="14" />
</button>
<span class="agenda-text">{{ item.text }}</span>
<span v-if="item.time" class="agenda-time">{{ item.time }}</span>
</div>
</div>
<div class="agenda-section">
<h3>Morgen</h3>
<div
v-for="(item, idx) in agendaTomorrow"
:key="'tomorrow-' + idx"
:class="['agenda-item', { done: item.done }]"
@click="toggleAgendaItem(item)"
>
<button class="agenda-check">
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
<Circle v-else :size="14" />
</button>
<span class="agenda-text">{{ item.text }}</span>
</div>
</div>
<div class="agenda-section">
<h3 class="overdue-heading">
<AlertTriangle :size="12" />
Überfällig
</h3>
<div
v-for="(item, idx) in agendaOverdue"
:key="'overdue-' + idx"
class="agenda-item overdue"
>
<button class="agenda-check">
<AlertTriangle :size="14" class="overdue-icon" />
</button>
<span class="agenda-text">{{ item.text }}</span>
<span class="agenda-sub">seit 2 Tagen</span>
</div>
</div>
</div>
</template>
<style scoped>
.agenda-panel {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.agenda-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.agenda-panel h2 {
font-size: 14px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.agenda-section h3 {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 700;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 4px;
padding-bottom: 4px;
border-bottom: 1px solid rgba(139, 124, 246, 0.06);
}
.overdue-heading {
color: #ef4444 !important;
border-bottom-color: rgba(239, 68, 68, 0.15) !important;
}
.agenda-item {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 6px;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.agenda-item:hover {
background: rgba(139, 124, 246, 0.04);
}
.agenda-item.done {
opacity: 0.5;
}
.agenda-item.done .agenda-text {
text-decoration: line-through;
}
.agenda-check {
display: grid;
place-items: center;
background: none;
border: none;
color: #6b7385;
padding: 0;
cursor: pointer;
flex-shrink: 0;
}
.agenda-check .checked {
color: #22c55e;
}
.overdue .overdue-icon {
color: #ef4444;
}
.agenda-text {
flex: 1;
font-size: 10.5px;
color: #7e8799;
}
.agenda-time {
font-size: 9px;
color: #6b7385;
flex-shrink: 0;
}
.agenda-sub {
font-size: 8px;
color: #ef4444;
flex-shrink: 0;
}
.agenda-item.overdue {
background: rgba(239, 68, 68, 0.06);
}
@media (max-width: 900px) {
.agenda-panel {
order: 3;
}
}
</style>
@@ -1,286 +0,0 @@
<script setup lang="ts">
import { X } from '@lucide/vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
defineProps<{
agent: AgentNodeData
runtime: string
}>()
defineEmits<{
close: []
}>()
</script>
<template>
<Teleport to="body">
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal-card" :style="{ '--agent-color': agent.color }">
<!-- Header -->
<div class="modal-header">
<div class="modal-title-row">
<div class="modal-avatar" :style="{ background: `${agent.color}18`, color: agent.color }">
<span class="avatar-letter">{{ agent.name.charAt(0) }}</span>
</div>
<div>
<h2>{{ agent.name }}</h2>
<span class="modal-role">{{ agent.role }}</span>
</div>
</div>
<button class="modal-close-btn" @click="$emit('close')" aria-label="Close">
<X :size="16" />
</button>
</div>
<!-- Description -->
<p class="modal-desc">{{ agent.description }}</p>
<!-- Current Task -->
<section class="modal-section">
<h3 class="section-label">Current Task</h3>
<p class="section-value">{{ agent.currentTask }}</p>
</section>
<!-- Goal + Progress -->
<section class="modal-section">
<h3 class="section-label">Goal</h3>
<p class="section-value">{{ agent.goal }}</p>
<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>
</div>
</section>
<!-- Working Feed -->
<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"
>
<span class="step-dot"></span>
<span class="step-text">{{ step }}</span>
</div>
</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>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
animation: overlay-in 0.2s ease;
}
@keyframes overlay-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal-card {
width: min(480px, 100%);
max-height: 80vh;
overflow-y: auto;
padding: 24px;
background: rgba(18, 22, 30, 0.96);
border: 1px solid color-mix(in srgb, var(--agent-color) 25%, transparent);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
animation: card-in 0.25s ease;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
@keyframes card-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-card::-webkit-scrollbar {
width: 5px;
}
.modal-card::-webkit-scrollbar-track {
background: transparent;
}
.modal-card::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
border-radius: 3px;
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-title-row {
display: flex;
align-items: center;
gap: 14px;
}
.modal-avatar {
width: 44px;
height: 44px;
display: grid;
place-items: center;
border-radius: 12px;
flex-shrink: 0;
}
.avatar-letter {
font-size: 18px;
font-weight: 700;
}
.modal-title-row h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #e8eaf0;
}
.modal-role {
font-size: 10px;
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;
background: transparent;
color: #6b7385;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.modal-close-btn:hover {
border-color: rgba(255, 255, 255, 0.15);
color: #e8eaf0;
}
.modal-desc {
font-size: 11px;
line-height: 1.55;
color: #7e8799;
margin: 0 0 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.modal-section {
margin-bottom: 16px;
}
.section-label {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #6b7385;
margin: 0 0 6px;
}
.section-value {
margin: 0;
font-size: 12px;
color: #e8eaf0;
line-height: 1.4;
}
/* Progress */
.progress-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.progress-pct {
font-size: 11px;
font-weight: 600;
color: #7e8799;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.progress-track {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.06);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
background: var(--agent-color);
transition: width 0.5s ease;
}
/* 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;
}
/* Footer */
.modal-footer {
display: flex;
gap: 10px;
padding-top: 14px;
margin-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.footer-badge {
font-size: 9px;
font-weight: 600;
padding: 4px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: #7e8799;
}
</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>
@@ -1,296 +0,0 @@
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { Bot, Send, LoaderCircle } from '@lucide/vue'
import type { ChatMessage } from '../../composables/useDashboardData'
const props = defineProps<{
messages: ChatMessage[]
irisBusy: boolean
irisFocus: string
}>()
const emit = defineEmits<{
send: [text: string]
}>()
const inputText = ref('')
const chatListRef = ref<HTMLElement | null>(null)
function sendMessage(): void {
if (!inputText.value.trim()) return
emit('send', inputText.value)
inputText.value = ''
}
watch(
() => props.messages.length,
async () => {
await nextTick()
if (chatListRef.value) {
chatListRef.value.scrollTop = chatListRef.value.scrollHeight
}
}
)
</script>
<template>
<div class="chat-panel">
<div class="chat-header">
<div class="chat-header-left">
<Bot :size="16" class="chat-header-icon" />
<h2>Iris Chat</h2>
</div>
<div v-if="irisBusy" class="busy-badge">
<LoaderCircle :size="10" class="spin" />
<span>Busy</span>
</div>
</div>
<!-- Focus Bar -->
<div v-if="irisBusy && irisFocus" class="focus-bar">
<span class="focus-label">Current Focus</span>
<span class="focus-text">{{ irisFocus }}</span>
</div>
<!-- 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>
</div>
<!-- Input -->
<div class="chat-input-row">
<input
v-model="inputText"
type="text"
placeholder="Type a message..."
@keyup.enter="sendMessage"
/>
<button
class="send-btn"
:disabled="!inputText.trim()"
@click="sendMessage"
aria-label="Send"
>
<Send :size="14" />
</button>
</div>
</div>
</template>
<style scoped>
.chat-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 360px;
max-height: 480px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
overflow: hidden;
}
.chat-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-header-left {
display: flex;
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 {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 16px;
background: rgba(234, 179, 8, 0.04);
border-bottom: 1px solid rgba(234, 179, 8, 0.08);
}
.focus-label {
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #eab308;
}
.focus-text {
font-size: 10px;
color: #7e8799;
line-height: 1.3;
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.chat-messages::-webkit-scrollbar {
width: 5px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: rgba(139, 124, 246, 0.2);
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;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.chat-input-row input {
flex: 1;
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 input::placeholder {
color: #6b7385;
}
.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;
}
.send-btn:disabled {
opacity: 0.35;
cursor: default;
}
.send-btn:not(:disabled):hover {
opacity: 0.85;
}
</style>
@@ -1,323 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Bot, Sparkles, MessageSquareText, ListTodo, Zap, FileText, Send, Lightbulb } from '@lucide/vue'
import { useTime } from '../../composables/useTime'
import { useOperationsStore } from '../../stores/operations'
interface Suggestion {
text: string
}
const { greeting } = useTime()
const store = useOperationsStore()
const chatInput = ref('')
const meters = computed(() => {
const tasks = store.snapshot.tasks
return {
openTasks: store.snapshot.metrics.queuedTasks,
blocked: store.snapshot.metrics.incidents,
critical: tasks.filter(t => t.state === 'Blocked').length,
active: tasks.filter(t => t.state === 'In progress').length,
}
})
const suggestions = ref<Suggestion[]>([
{ text: 'Du solltest zuerst das Dungeon-System abschließen.' },
{ text: 'Die Dokumentation wurde seit 3 Tagen nicht aktualisiert.' },
{ text: 'Das Projekt OpenClaw benötigt Aufmerksamkeit.' },
])
function sendChat() {
if (!chatInput.value.trim()) return
console.log('[Iris] Chat received:', chatInput.value)
chatInput.value = ''
}
</script>
<template>
<aside class="iris-panel">
<div class="iris-profile">
<div class="iris-avatar">
<Bot :size="32" />
</div>
<div class="iris-name-block">
<h2>Iris</h2>
<span class="iris-role">Chief of Staff</span>
</div>
</div>
<p class="iris-greeting">{{ greeting }} Bao.</p>
<p class="iris-status">Du hast heute <strong>4 wichtige Punkte.</strong></p>
<div class="meters">
<div class="meter-item">
<span class="meter-value">{{ meters.openTasks }}</span>
<span class="meter-label">Offene Aufgaben</span>
</div>
<div class="meter-item">
<span class="meter-value meter-blocked">{{ meters.blocked }}</span>
<span class="meter-label">Blockiert</span>
</div>
<div class="meter-item">
<span class="meter-value meter-critical">{{ meters.critical }}</span>
<span class="meter-label">Kritisch</span>
</div>
<div class="meter-item">
<span class="meter-value meter-active">{{ meters.active }}</span>
<span class="meter-label">Aktiv</span>
</div>
</div>
<div class="suggestions">
<h3><Sparkles :size="14" /> Vorschläge</h3>
<div
v-for="(s, idx) in suggestions"
:key="idx"
class="suggestion-card"
>
<Lightbulb :size="14" class="bulb" />
<span>{{ s.text }}</span>
</div>
</div>
<div class="quick-actions">
<button class="qa-btn">
<MessageSquareText :size="14" /> Chat öffnen
</button>
<button class="qa-btn">
<ListTodo :size="14" /> Tagesplanung
</button>
<button class="qa-btn">
<Zap :size="14" /> Prioritäten setzen
</button>
<button class="qa-btn">
<FileText :size="14" /> Zusammenfassung
</button>
</div>
<div class="chat-box">
<div class="chat-input-row">
<input
v-model="chatInput"
type="text"
placeholder="Frag Iris etwas..."
@keyup.enter="sendChat"
/>
<button class="chat-send" @click="sendChat">
<Send :size="14" />
</button>
</div>
</div>
</aside>
</template>
<style scoped>
.iris-panel {
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px;
background: rgba(22, 27, 34, 0.8);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
transition: all 0.2s ease;
}
.iris-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.iris-profile {
display: flex;
align-items: center;
gap: 12px;
}
.iris-avatar {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
display: grid;
place-items: center;
flex-shrink: 0;
}
.iris-name-block h2 {
font-size: 18px;
font-weight: 700;
margin: 0;
line-height: 1.2;
color: #e8eaf0;
}
.iris-role {
font-size: 10px;
color: #a78bfa;
font-weight: 600;
letter-spacing: 0.04em;
}
.iris-greeting {
font-size: 15px;
font-weight: 600;
margin: 0;
color: #e8eaf0;
}
.iris-status {
font-size: 11px;
color: #7e8799;
margin: 0;
}
.iris-status strong {
color: #e8eaf0;
}
/* Meters */
.meters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.meter-item {
background: rgba(139, 124, 246, 0.06);
border: 1px solid rgba(139, 124, 246, 0.08);
border-radius: 10px;
padding: 8px;
text-align: center;
transition: all 0.2s ease;
}
.meter-item:hover {
border-color: rgba(139, 124, 246, 0.18);
background: rgba(139, 124, 246, 0.1);
}
.meter-value {
display: block;
font-size: 20px;
font-weight: 700;
color: #e8eaf0;
}
.meter-blocked { color: #eab308; }
.meter-critical { color: #ef4444; }
.meter-active { color: #3b82f6; }
.meter-label {
display: block;
font-size: 8px;
color: #6b7385;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 2px;
}
/* Suggestions */
.suggestions h3 {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 700;
color: #a78bfa;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0 0 6px;
}
.suggestion-card {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 7px 8px;
margin-bottom: 3px;
border-radius: 8px;
cursor: default;
transition: all 0.2s ease;
}
.suggestion-card:hover {
background: rgba(139, 124, 246, 0.08);
}
.suggestion-card .bulb {
color: #eab308;
flex-shrink: 0;
margin-top: 1px;
}
.suggestion-card span {
font-size: 10.5px;
line-height: 1.4;
color: #7e8799;
}
/* Quick Actions */
.quick-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.qa-btn {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
padding: 8px 10px;
border: 1px solid rgba(139, 124, 246, 0.1);
border-radius: 8px;
background: rgba(139, 124, 246, 0.04);
color: #7e8799;
font-size: 10.5px;
cursor: pointer;
transition: all 0.2s ease;
}
.qa-btn:hover {
background: rgba(139, 124, 246, 0.12);
border-color: rgba(139, 124, 246, 0.2);
color: #e8eaf0;
}
/* Chat Box */
.chat-box {
margin-top: auto;
}
.chat-input-row {
display: flex;
gap: 5px;
}
.chat-input-row input {
flex: 1;
padding: 7px 10px;
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 8px;
background: rgba(13, 17, 23, 0.6);
color: #e8eaf0;
font-size: 10.5px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.chat-input-row input:focus {
border-color: #a78bfa;
}
.chat-input-row input::placeholder {
color: #6b7385;
}
.chat-send {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: #a78bfa;
color: #fff;
cursor: pointer;
flex-shrink: 0;
transition: opacity 0.2s;
}
.chat-send:hover {
opacity: 0.85;
}
@media (max-width: 900px) {
.iris-panel {
order: 1;
}
}
</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,155 +0,0 @@
<script setup lang="ts">
import { Activity } from '@lucide/vue'
import type { FeedEntry } from '../../composables/useDashboardData'
defineProps<{
entries: FeedEntry[]
}>()
</script>
<template>
<div class="feed-panel">
<div class="feed-header">
<Activity :size="14" class="feed-icon" />
<h2>Operations Feed</h2>
</div>
<div class="feed-list">
<TransitionGroup name="feed">
<div
v-for="(entry, idx) in entries.slice(0, 8)"
:key="entry.timestamp + '-' + idx"
class="feed-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>
</TransitionGroup>
<div v-if="entries.length === 0" class="feed-empty">
<span>No operations recorded yet.</span>
</div>
</div>
</div>
</template>
<style scoped>
.feed-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);
}
.feed-panel:hover {
border-color: rgba(139, 124, 246, 0.15);
}
.feed-header {
display: flex;
align-items: center;
gap: 6px;
}
.feed-icon {
color: #a78bfa;
}
.feed-header h2 {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #e8eaf0;
}
.feed-list {
display: flex;
flex-direction: column;
gap: 2px;
position: relative;
}
.feed-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-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: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-empty {
text-align: center;
padding: 12px 0;
font-size: 10px;
color: #6b7385;
}
/* TransitionGroup */
.feed-enter-active {
transition: all 0.3s ease;
}
.feed-leave-active {
transition: all 0.3s ease;
position: absolute;
}
.feed-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.feed-leave-to {
opacity: 0;
transform: translateX(10px);
}
.feed-move {
transition: transform 0.3s ease;
}
</style>
@@ -1,344 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import {
ListTodo,
ChevronUp,
ChevronDown,
ArrowUp,
ArrowDown,
Trash2,
Zap,
} from '@lucide/vue'
import type { QueueItem } from '../../composables/useDashboardData'
const props = defineProps<{
items: QueueItem[]
}>()
const emit = defineEmits<{
remove: [id: string]
moveUp: [id: string]
moveDown: [id: string]
changePriority: [id: string, priority: QueueItem['priority']]
executeNow: [id: string]
}>()
const expanded = ref(true)
const priorityColor: Record<string, string> = {
high: '#ef4444',
medium: '#eab308',
low: '#6b7385',
}
const dragIndex = ref<number | null>(null)
const dragOverIndex = ref<number | null>(null)
function onDragStart(idx: number): void {
dragIndex.value = idx
}
function onDragOver(e: DragEvent, idx: number): void {
e.preventDefault()
dragOverIndex.value = idx
}
function onDrop(): void {
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
const id = props.items[dragIndex.value]?.id
if (id) {
const targetId = props.items[dragOverIndex.value]?.id
if (targetId) {
if (dragIndex.value < dragOverIndex.value) {
for (let i = dragIndex.value; i < dragOverIndex.value; i++) {
emit('moveDown', props.items[i]!.id)
}
} else {
for (let i = dragIndex.value; i > dragOverIndex.value; i--) {
emit('moveUp', props.items[i]!.id)
}
}
}
}
}
dragIndex.value = null
dragOverIndex.value = null
}
function onDragEnd(): void {
dragIndex.value = null
dragOverIndex.value = null
}
</script>
<template>
<div class="queue-panel">
<div class="queue-header" @click="expanded = !expanded">
<div class="queue-header-left">
<ListTodo :size="14" class="queue-icon" />
<h2>Queue</h2>
<span class="queue-count">{{ items.length }}</span>
</div>
<button class="queue-toggle" aria-label="Toggle">
<ChevronUp v-if="expanded" :size="14" />
<ChevronDown v-else :size="14" />
</button>
</div>
<Transition name="queue-expand">
<div v-if="expanded" class="queue-list">
<div
v-for="(item, idx) in items"
:key="item.id"
class="queue-item"
:class="{
'drag-source': dragIndex === idx,
'drag-over': dragOverIndex === idx && dragIndex !== idx,
}"
draggable="true"
@dragstart="onDragStart(idx)"
@dragover="onDragOver($event, idx)"
@drop="onDrop"
@dragend="onDragEnd"
>
<div class="queue-item-body">
<div class="queue-item-head">
<span
class="priority-badge"
:style="{
color: priorityColor[item.priority],
borderColor: `${priorityColor[item.priority]}30`,
background: `${priorityColor[item.priority]}10`,
}"
>
{{ item.priority }}
</span>
<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)"
>
<Zap :size="12" />
</button>
<button
class="q-action-btn"
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)"
>
<ArrowDown :size="12" />
</button>
<button
class="q-action-btn q-action-danger"
title="Remove"
@click.stop="emit('remove', item.id)"
>
<Trash2 :size="12" />
</button>
</div>
</div>
<div v-if="items.length === 0" class="queue-empty">
<p>Queue is empty</p>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.queue-panel {
display: flex;
flex-direction: column;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
}
.queue-panel:hover {
border-color: rgba(139, 124, 246, 0.18);
}
.queue-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
user-select: none;
}
.queue-header-left {
display: flex;
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;
flex-direction: column;
padding: 0 10px 10px;
gap: 4px;
}
.queue-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s, opacity 0.15s;
cursor: grab;
}
.queue-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.queue-item:active {
cursor: grabbing;
}
.drag-source {
opacity: 0.4;
}
.drag-over {
background: rgba(167, 139, 250, 0.08);
}
.queue-item-body {
flex: 1;
min-width: 0;
}
.queue-item-head {
display: flex;
align-items: center;
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;
font-variant-numeric: tabular-nums;
}
.queue-text {
margin: 0;
font-size: 9.5px;
color: #7e8799;
line-height: 1.3;
}
/* Actions */
.queue-actions {
display: flex;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
margin-top: 2px;
}
.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;
padding: 16px 0;
}
.queue-empty p {
margin: 0;
font-size: 10px;
color: #6b7385;
}
/* Transition */
.queue-expand-enter-active,
.queue-expand-leave-active {
transition: all 0.2s ease;
}
.queue-expand-enter-from,
.queue-expand-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>
@@ -1,92 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
const recentlyFinished = ref([
'Docker Image gebaut',
'Memory Compression',
'Enemy AI verbessert',
'Daily Backup',
'TeamView deployt',
'Config-Editor live',
])
function onChipClick(item: string) {
console.log('[Dashboard] Recently finished:', item)
}
</script>
<template>
<div class="finished-section">
<h3>Recently Finished</h3>
<div class="finished-scroll">
<span
v-for="(item, idx) in recentlyFinished"
:key="idx"
class="finished-chip"
role="button"
tabindex="0"
@click="onChipClick(item)"
@keyup.enter="onChipClick(item)"
>
{{ item }}
</span>
</div>
</div>
</template>
<style scoped>
.finished-section {
display: flex;
align-items: center;
gap: 10px;
}
.finished-section h3 {
font-size: 10px;
font-weight: 700;
color: #7e8799;
text-transform: uppercase;
letter-spacing: 0.08em;
margin: 0;
white-space: nowrap;
flex-shrink: 0;
}
.finished-scroll {
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 2px;
}
.finished-scroll::-webkit-scrollbar {
display: none;
}
.finished-chip {
flex-shrink: 0;
padding: 5px 12px;
border: 1px solid rgba(139, 124, 246, 0.1);
border-radius: 20px;
background: rgba(139, 124, 246, 0.06);
color: #7e8799;
font-size: 9.5px;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s ease;
}
.finished-chip:hover {
background: rgba(139, 124, 246, 0.12);
border-color: rgba(139, 124, 246, 0.2);
color: #e8eaf0;
}
.finished-chip:focus-visible {
outline: 2px solid #a78bfa;
outline-offset: 2px;
}
@media (max-width: 900px) {
.finished-section {
flex-direction: column;
align-items: flex-start;
}
}
</style>
@@ -1,345 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Bot, Sparkles } from '@lucide/vue'
import AgentNode from './AgentNode.vue'
import AgentModal from './AgentModal.vue'
import type { AgentNodeData } from '../../composables/useDashboardData'
const props = defineProps<{
agents: AgentNodeData[]
irisRuntime: string
getAgentRuntime: (id: string) => string
irisFocus: string
}>()
const selectedAgent = ref<AgentNodeData | null>(null)
function onAgentSelect(agentId: string): void {
const agent = props.agents.find(a => a.id === agentId)
if (agent) selectedAgent.value = agent
}
function closeModal(): void {
selectedAgent.value = null
}
const agentColorMap: Record<string, string> = {
developer: '#3b82f6',
devops: '#eab308',
researcher: '#22c55e',
reviewer: '#a855f7',
}
const agentLineActive: Record<string, boolean> = {
developer: true,
devops: false,
researcher: true,
reviewer: false,
}
const NETWORK_W = 440
const IRIS_CX = NETWORK_W / 2
const IRIS_CY = 80
const AGENT_START_Y = 170
const agentPositions = computed(() => [
{ id: 'developer', x: 60, y: AGENT_START_Y },
{ id: 'researcher', x: NETWORK_W - 60, y: AGENT_START_Y },
{ id: 'devops', x: 60, y: AGENT_START_Y + 110 },
{ id: 'reviewer', x: NETWORK_W - 60, y: AGENT_START_Y + 110 },
])
const activeLines = computed(() =>
agentPositions.value.filter(p => agentLineActive[p.id])
)
</script>
<template>
<div class="team-network">
<!-- Iris Node -->
<div class="iris-node">
<div class="iris-avatar-ring">
<svg class="ring-svg" viewBox="0 0 60 60" width="60" height="60">
<circle cx="30" cy="30" r="27" fill="none" stroke="rgba(167,139,250,0.12)" stroke-width="2" />
<circle
cx="30" cy="30" r="27"
fill="none" stroke="#a78bfa" stroke-width="2"
stroke-dasharray="169.6" stroke-dashoffset="42"
stroke-linecap="round"
transform="rotate(-90 30 30)"
class="ring-arc"
/>
</svg>
<div class="iris-avatar-inner">
<Bot :size="26" />
</div>
</div>
<div class="iris-name-block">
<h2>Iris</h2>
<span class="iris-role-label">Chief of Staff</span>
</div>
<p class="iris-tagline">Breaking down tasks and coordinating specialists</p>
<div class="iris-runtime">
<span class="rt-label">Session Runtime</span>
<span class="rt-value">{{ irisRuntime }}</span>
</div>
</div>
<!-- SVG Connections -->
<svg
class="network-svg"
:viewBox="`0 0 ${NETWORK_W} 400`"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<filter id="lineglow">
<feGaussianBlur stdDeviation="2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<line
v-for="pos in agentPositions"
:key="'conn-' + pos.id"
:x1="IRIS_CX" :y1="IRIS_CY + 32"
:x2="pos.x + 22" :y2="pos.y"
:stroke="agentColorMap[pos.id]"
:stroke-width="agentLineActive[pos.id] ? 1.5 : 1"
:opacity="agentLineActive[pos.id] ? 0.4 : 0.1"
class="conn-line"
:class="{ 'line-pulse': agentLineActive[pos.id] }"
filter="url(#lineglow)"
/>
<g v-for="pos in activeLines" :key="'fx-' + pos.id">
<circle
:cx="pos.x + 22" :cy="pos.y"
r="2.5" fill="#ffffff"
class="pulse-end" :style="{ '--pulse-color': agentColorMap[pos.id] }"
/>
<circle
:cx="IRIS_CX" :cy="IRIS_CY + 32"
r="2.5" fill="#ffffff"
class="pulse-origin"
/>
</g>
</svg>
<!-- Agent Cards -->
<div class="agents-grid">
<AgentNode
v-for="agent in agents"
:key="agent.id"
:agent="agent"
:runtime="getAgentRuntime(agent.id)"
@select="onAgentSelect"
/>
</div>
<!-- Focus Banner -->
<div v-if="irisFocus" class="focus-banner">
<Sparkles :size="12" class="focus-icon" />
<span>{{ irisFocus }}</span>
</div>
<!-- Agent Modal -->
<AgentModal
v-if="selectedAgent"
:agent="selectedAgent"
:runtime="getAgentRuntime(selectedAgent.id)"
@close="closeModal"
/>
</div>
</template>
<style scoped>
.team-network {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px 20px 20px;
background: rgba(22, 27, 34, 0.75);
border: 1px solid rgba(139, 124, 246, 0.12);
border-radius: 16px;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
transition: border-color 0.2s ease;
position: relative;
overflow: hidden;
min-height: 520px;
}
.team-network:hover {
border-color: rgba(139, 124, 246, 0.18);
}
/* Iris Node */
.iris-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 20px 28px;
background: rgba(167, 139, 250, 0.06);
border: 1px solid rgba(167, 139, 250, 0.15);
border-radius: 14px;
position: relative;
z-index: 2;
width: 100%;
max-width: 320px;
}
.iris-avatar-ring {
position: relative;
width: 60px;
height: 60px;
}
.ring-svg {
position: absolute;
inset: 0;
}
.ring-arc {
animation: iris-spin 3s linear infinite;
}
@keyframes iris-spin {
to { transform: rotate(270deg); }
}
.iris-avatar-inner {
width: 60px;
height: 60px;
display: grid;
place-items: center;
border-radius: 50%;
background: rgba(167, 139, 250, 0.15);
color: #a78bfa;
}
.iris-name-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.iris-name-block h2 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #e8eaf0;
}
.iris-role-label {
font-size: 9px;
color: #a78bfa;
font-weight: 600;
letter-spacing: 0.05em;
}
.iris-tagline {
margin: 2px 0 0;
font-size: 10.5px;
color: #6b7385;
text-align: center;
line-height: 1.35;
}
.iris-runtime {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
padding: 5px 14px;
background: rgba(167, 139, 250, 0.08);
border: 1px solid rgba(167, 139, 250, 0.1);
border-radius: 20px;
}
.rt-label {
font-size: 8px;
color: #7e8799;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.rt-value {
font-size: 14px;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #a78bfa;
}
/* SVG Lines */
.network-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.conn-line {
transition: opacity 0.4s ease, stroke-width 0.4s ease;
}
.line-pulse {
animation: line-glow 2s ease-in-out infinite;
}
@keyframes line-glow {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.7; }
}
.pulse-origin {
animation: pulse-origin 2s ease-in-out infinite;
}
.pulse-end {
animation: pulse-end 2s ease-in-out infinite;
}
@keyframes pulse-origin {
0%, 100% { opacity: 0; r: 1.5; }
50% { opacity: 0.8; r: 3.5; }
}
@keyframes pulse-end {
0%, 100% { opacity: 0.2; r: 1.5; }
50% { opacity: 0.9; r: 3; }
}
/* Agent Cards */
.agents-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
width: 100%;
position: relative;
z-index: 2;
margin-top: 4px;
}
/* Focus Banner */
.focus-banner {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
background: rgba(234, 179, 8, 0.05);
border: 1px solid rgba(234, 179, 8, 0.1);
border-radius: 10px;
margin-top: 4px;
position: relative;
z-index: 2;
}
.focus-icon {
color: #eab308;
flex-shrink: 0;
}
.focus-banner span {
font-size: 10.5px;
color: #eab308;
line-height: 1.35;
}
@media (max-width: 700px) {
.agents-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,614 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import type { AgentDetailData } from './types'
import { icons } from '../../../composables/icons'
const props = defineProps<{
agent: AgentDetailData
agentOrder: string[]
}>()
const emit = defineEmits<{
close: []
select: [id: string]
changeModel: [agentId: string, modelId: string]
}>()
/* ── Progress animation ────────────────────────── */
const displayProgress = ref(0)
function animateProgress() {
displayProgress.value = 0
setTimeout(() => { displayProgress.value = props.agent.progress }, 60)
}
/* ── Typewriter ────────────────────────────────── */
const thinkDisplay = ref('')
let thinkTimer: ReturnType<typeof setInterval> | null = null
function startTypewriter() {
if (thinkTimer) clearInterval(thinkTimer)
thinkDisplay.value = ''
if (!props.agent.think) return
const text = props.agent.think
let i = 0
thinkTimer = setInterval(() => {
thinkDisplay.value = text.slice(0, i)
i = i >= text.length ? 0 : i + 1
}, 38)
}
/* ── Selected model ────────────────────────────── */
const selectedModel = ref(props.agent.model)
watch(() => props.agent.id, () => {
selectedModel.value = props.agent.model
animateProgress()
startTypewriter()
})
function selectModel(alias: string) {
selectedModel.value = alias
emit('changeModel', props.agent.id, alias)
}
/* ── Keyboard / Backdrop ───────────────────────── */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { emit('close'); return }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault()
const idx = props.agentOrder.indexOf(props.agent.id)
if (idx === -1) return
const next = e.key === 'ArrowRight'
? (idx + 1) % props.agentOrder.length
: (idx - 1 + props.agentOrder.length) % props.agentOrder.length
emit('select', props.agentOrder[next])
}
}
function onBackdrop(e: MouseEvent) {
if ((e.target as HTMLElement).classList.contains('modal-ov')) emit('close')
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
animateProgress()
startTypewriter()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
if (thinkTimer) clearInterval(thinkTimer)
})
const statusColors: Record<string, string> = {
work: 'var(--st-work)',
think: 'var(--st-think)',
idle: 'var(--st-idle)',
block: 'var(--st-block)',
}
function avatarLabel() {
if (props.agent.id === 'iris') return 'IR'
if (props.agent.name === 'Full-Stack Developer') return '</>'
return props.agent.name.slice(0, 2).toUpperCase()
}
</script>
<template>
<div class="modal-ov" @click="onBackdrop">
<div class="modal-card">
<!-- Close -->
<button class="m-close" @click="emit('close')">×</button>
<!-- Header -->
<div class="m-head">
<div :class="['m-av', { iris: agent.id === 'iris' }]">
{{ avatarLabel() }}
</div>
<div style="flex:1; min-width:0">
<div class="m-name">{{ agent.name }}</div>
<div class="m-sub">
<span :class="['badge', agent.roleBadge]">{{ agent.role }}</span>
<span class="m-pill">{{ selectedModel }}</span>
<span class="m-status" :style="{ color: statusColors[agent.status] }">
<span class="dot" :class="agent.status"></span>
{{ agent.statusLabel }}
</span>
</div>
</div>
</div>
<!-- Aktuelle Aufgabe -->
<div v-if="agent.task" class="m-sec">
<h4>Aktuelle Aufgabe</h4>
<div class="m-task">{{ agent.task }}</div>
<div class="m-goal">
<span v-html="icons.target || ''"></span>
Ziel: {{ agent.goal }}
</div>
<div class="m-bar" :class="{ work: agent.status === 'work' }">
<i :style="{ width: displayProgress + '%' }"></i>
</div>
<div class="m-pct-row">
<div class="m-pct grad-tx">{{ displayProgress }}%</div>
<div class="m-next"> {{ agent.next }}</div>
</div>
</div>
<!-- Metriken -->
<div class="m-sec">
<h4>Metriken</h4>
<div class="m-metrics">
<div class="m-metric">
<div class="mk">Elapsed</div>
<div class="mv">{{ agent.elapsed }}</div>
</div>
<div class="m-metric">
<div class="mk">Token</div>
<div class="mv">{{ agent.tokens }}</div>
</div>
<div class="m-metric">
<div class="mk">Kosten</div>
<div class="mv grad-tx">${{ agent.cost }}</div>
</div>
<div class="m-metric">
<div class="mk">Fortschritt</div>
<div class="mv">{{ agent.progress }}%</div>
</div>
</div>
</div>
<!-- Live Thinking -->
<div v-if="agent.think" class="m-sec">
<h4>Live Thinking</h4>
<div class="m-think">{{ thinkDisplay }}<span class="caret"></span></div>
</div>
<div v-if="agent.activity.length" class="m-sec">
<h4>Agent Activity</h4>
<div class="m-activity">
<div v-for="(entry, index) in agent.activity" :key="index" class="m-activity-row">
<span class="m-activity-time">{{ entry.time }}</span>
<span class="m-activity-text">{{ entry.text }}</span>
</div>
</div>
</div>
<!-- Modell wählen -->
<div class="m-sec">
<h4>Modell wählen</h4>
<div class="m-models">
<button
v-for="m in agent.availableModels"
:key="m.id"
:class="['m-model-btn', { active: m.alias === selectedModel }]"
@click="selectModel(m.alias)"
>{{ m.alias }}</button>
</div>
</div>
<!-- MD Footer -->
<div v-if="agent.md" class="m-md">
<span class="dot work"></span>
Synced: <span class="m-md-path">{{ agent.md }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────── */
.modal-ov {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,4,16,.78);
backdrop-filter: blur(16px);
animation: ov-in .2s ease-out;
}
@keyframes ov-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Card ────────────────────────────────────── */
.modal-card {
width: 640px;
max-height: 88vh;
overflow-y: auto;
border-radius: 20px;
position: relative;
background: linear-gradient(160deg, rgba(22,18,50,.97), rgba(10,8,28,.97));
border: 1px solid rgba(150,140,255,.28);
box-shadow:
0 0 0 1px rgba(124,108,255,.15),
0 32px 80px -16px rgba(0,0,0,.8),
0 0 60px -10px rgba(124,108,255,.35);
animation: card-in .24s cubic-bezier(.2,.8,.3,1);
}
@keyframes card-in {
from { opacity: 0; transform: scale(.94) translateY(12px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.modal-card::-webkit-scrollbar { width: 7px; }
.modal-card::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 7px;
border: 2px solid transparent;
background-clip: padding-box;
}
.modal-card::-webkit-scrollbar-track { background: transparent; }
/* ── Close ───────────────────────────────────── */
.m-close {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
font-size: 20px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: .15s;
z-index: 2;
}
.m-close:hover {
background: rgba(124,108,255,.18);
color: var(--tx);
}
/* ── Head ────────────────────────────────────── */
.m-head {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 26px 26px 20px;
}
.m-av {
width: 56px;
height: 56px;
border-radius: 15px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 16px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
}
.m-av.iris {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.m-name {
font-family: 'Space Grotesk', sans-serif;
font-size: 21px;
font-weight: 700;
line-height: 1.1;
color: var(--tx);
}
.m-sub {
display: flex;
align-items: center;
gap: 9px;
margin-top: 7px;
flex-wrap: wrap;
}
/* ── Badges ──────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 9px;
border-radius: 7px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
}
.badge-blue { background:rgba(79,124,255,.14); color:#9db6ff; border-color:rgba(79,124,255,.3); }
.badge-purple { background:rgba(181,87,246,.14); color:#d7a8ff; border-color:rgba(181,87,246,.3); }
.badge-amber { background:rgba(251,191,36,.13); color:#fcd34d; border-color:rgba(251,191,36,.3); }
.badge-green { background:rgba(61,220,151,.13); color:#7ef0bd; border-color:rgba(61,220,151,.3); }
.badge-cyan { background:rgba(52,214,245,.13); color:#8ee9fb; border-color:rgba(52,214,245,.3); }
.badge-rose { background:rgba(251,113,133,.13); color:#fda4b0; border-color:rgba(251,113,133,.3); }
.badge-slate { background:rgba(150,140,255,.08); color:var(--tx-2); border-color:var(--line-2); }
.m-pill {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 11px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.m-status {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 600;
}
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work { background: var(--st-work); box-shadow: 0 0 0 0 rgba(61,220,151,.5); animation: pulse-work 1.8s infinite; }
.dot.think { background: var(--st-think); box-shadow: 0 0 0 0 rgba(52,214,245,.5); animation: pulse-think 1.6s infinite; }
.dot.idle { background: var(--st-idle); }
.dot.block { background: var(--st-block); }
/* ── Section ─────────────────────────────────── */
.m-sec {
padding: 16px 26px;
border-top: 1px solid var(--line);
}
.m-sec h4 {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
margin: 0 0 13px;
}
/* ── Task ────────────────────────────────────── */
.m-task {
font-size: 15px;
font-weight: 700;
margin-bottom: 8px;
color: var(--tx);
}
.m-goal {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--tx-2);
margin-bottom: 14px;
}
.m-goal :deep(svg) {
width: 14px;
height: 14px;
color: var(--a-mid);
flex: 0 0 auto;
}
/* ── Progress Bar ────────────────────────────── */
.m-bar {
height: 10px;
border-radius: 10px;
background: rgba(124,108,255,.12);
overflow: hidden;
position: relative;
}
.m-bar i {
display: block;
height: 100%;
border-radius: 10px;
background: var(--grad);
box-shadow: 0 0 14px -2px rgba(124,108,255,.8);
transition: width .6s ease;
position: relative;
}
.m-bar.work i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
box-shadow: var(--glow-work);
}
.m-bar i::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
animation: shimmer 2s linear infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(180%); }
}
.m-pct-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.m-pct {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 700;
}
.grad-tx {
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.m-next {
font-size: 12px;
color: var(--tx-3);
font-family: 'JetBrains Mono', monospace;
}
/* ── Metrics ─────────────────────────────────── */
.m-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.m-metric {
padding: 11px 13px;
border-radius: 12px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
}
.mk {
font-size: 10px;
color: var(--tx-3);
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
}
.mv {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
font-weight: 600;
margin-top: 5px;
color: var(--tx);
}
/* ── Live Thinking ───────────────────────────── */
.m-think {
background: rgba(5,20,36,.7);
border: 1px solid rgba(52,214,245,.2);
border-radius: 13px;
padding: 14px 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
line-height: 1.7;
color: #9fe8fb;
min-height: 72px;
position: relative;
overflow: hidden;
}
.m-think::before {
content: '▶ thinking';
position: absolute;
top: 10px;
right: 14px;
font-size: 9px;
color: var(--st-think);
letter-spacing: .12em;
opacity: .7;
}
.caret::after {
content: '▍';
animation: blink 1s steps(1) infinite;
color: var(--st-think);
}
@keyframes blink { 50% { opacity: 0; } }
.m-activity {
display: flex;
flex-direction: column;
gap: 10px;
}
.m-activity-row {
display: grid;
grid-template-columns: 72px 1fr;
gap: 10px;
align-items: start;
padding: 10px 12px;
border-radius: 12px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
}
.m-activity-time {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
}
.m-activity-text {
font-size: 12px;
line-height: 1.45;
color: var(--tx-2);
}
/* ── Models ──────────────────────────────────── */
.m-models {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.m-model-btn {
height: 34px;
padding: 0 14px;
border-radius: 9px;
font-family: 'Manrope', sans-serif;
font-size: 12.5px;
font-weight: 600;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.06);
color: var(--tx-2);
cursor: pointer;
transition: .15s;
}
.m-model-btn:hover {
border-color: var(--line-3);
color: var(--tx);
}
.m-model-btn.active {
background: var(--grad);
border: none;
color: #fff;
box-shadow: var(--glow-purple);
}
/* ── MD Footer ───────────────────────────────── */
.m-md {
display: flex;
align-items: center;
gap: 9px;
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
color: var(--tx-2);
padding: 12px 26px;
border-top: 1px solid var(--line);
}
.m-md .dot { width: 7px; height: 7px; }
.m-md-path { color: var(--tx-3); }
</style>
@@ -0,0 +1,276 @@
<script setup lang="ts">
/**
* AgentNode — Einzelner Agenten-Knoten im FlowCanvas
*
* Props:
* agent AgentNodeData
* left x-Position in % (0100)
* top y-Position in % (0100)
* entering true wenn Node gerade frisch ins DOM kam (Enter-Animation)
*
* Emits:
* select Agent ausgewählt (id)
*/
import type { AgentNodeData } from '../../../composables/useFlowLayout'
const props = defineProps<{
agent: AgentNodeData
left: number
top: number
entering?: boolean
}>()
defineEmits<{
select: [id: string]
}>()
</script>
<template>
<div
:class="[
'node',
agent.id === 'iris' ? 'is-iris' : `is-${agent.status}`,
{ entering }
]"
:style="{ left: left + '%', top: top + '%' }"
tabindex="0"
role="button"
:aria-label="`${agent.name} öffnen`"
@keydown.enter.prevent="$emit('select', agent.id)"
@keydown.space.prevent="$emit('select', agent.id)"
>
<div class="ncard">
<!-- Header: Avatar + Name + Role + Status-Dot -->
<div class="nc-top">
<div :class="['nc-av', { 'iris-av': agent.id === 'iris' }]">
<span v-html="agent.avatar === '</>' ? '&lt;/&gt;' : agent.avatar"></span>
</div>
<div class="nc-info">
<div class="nc-name">{{ agent.name }}</div>
<div class="nc-role">{{ agent.role }}</div>
</div>
<span :class="['nc-stat', 'dot', agent.status]"></span>
</div>
<!-- Task (2-line clamp) -->
<div class="nc-task">{{ agent.task || 'Bereit · ' + agent.next }}</div>
<!-- Progress Bar -->
<div class="nc-bar">
<i :style="{ width: (agent.progress || 3) + '%' }"></i>
</div>
<!-- Meta-Zeile -->
<div class="nc-meta">
<span
class="st"
:style="{ color: `var(--st-${agent.status})` }"
>
{{ agent.statusLabel }}
</span>
<span>{{ agent.task ? (agent.progress + '% · ' + agent.elapsed) : agent.model }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.node {
position: absolute;
transform: translate(-50%, -50%);
z-index: 3;
width: 188px;
transition:
left 0.55s cubic-bezier(.4, 0, .2, 1),
top 0.55s cubic-bezier(.4, 0, .2, 1),
opacity 0.35s,
scale 0.35s;
cursor: pointer;
user-select: none;
}
.node.entering {
opacity: 0;
scale: 0.7;
}
.ncard {
padding: 11px 12px;
border-radius: 13px;
background: var(--glass-2);
border: 1px solid var(--line-2);
backdrop-filter: blur(10px);
transition: transform 0.18s;
}
.ncard:hover {
transform: translateY(-2px);
}
/* Status-glow border */
.node.is-work .ncard {
border-color: rgba(61, 220, 151, 0.45);
box-shadow: 0 0 0 1px rgba(61, 220, 151, 0.2), 0 0 26px -6px rgba(61, 220, 151, 0.6);
}
.node.is-think .ncard {
border-color: rgba(52, 214, 245, 0.45);
box-shadow: 0 0 0 1px rgba(52, 214, 245, 0.2), 0 0 26px -6px rgba(52, 214, 245, 0.55);
}
.node.is-block .ncard {
border-color: rgba(251, 113, 133, 0.45);
box-shadow: 0 0 0 1px rgba(251, 113, 133, 0.2), 0 0 26px -6px rgba(251, 113, 133, 0.55);
}
.node.is-iris .ncard {
border-color: rgba(124, 108, 255, 0.55);
box-shadow: var(--glow);
background: linear-gradient(160deg, rgba(124, 108, 255, 0.2), rgba(28, 24, 64, 0.6));
}
.node.is-idle .ncard {
opacity: 0.7;
}
/* ── Card Content ────────────────────────────── */
.nc-top {
display: flex;
align-items: center;
gap: 9px;
}
.nc-av {
width: 32px;
height: 32px;
border-radius: 9px;
flex: 0 0 auto;
display: grid;
place-items: center;
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 11px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
color: var(--tx);
}
.nc-av.iris-av {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.nc-av :deep(svg) {
display: block;
}
.nc-info {
min-width: 0;
flex: 1;
}
.nc-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--tx);
}
.nc-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.nc-stat {
margin-left: auto;
flex: 0 0 auto;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
display: block;
}
.dot.work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61, 220, 151, 0.55);
animation: pulse-work 1.8s infinite;
}
.dot.think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52, 214, 245, 0.55);
animation: pulse-think 1.8s infinite;
}
.dot.idle {
background: var(--st-idle);
}
.dot.block {
background: var(--st-block);
box-shadow: 0 0 0 0 rgba(251, 113, 133, 0.55);
animation: pulse-block 1.8s infinite;
}
/* ── Task ────────────────────────────────────── */
.nc-task {
font-size: 11px;
color: var(--tx-2);
margin-top: 8px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 28px;
}
/* ── Progress Bar ────────────────────────────── */
.nc-bar {
height: 4px;
border-radius: 4px;
background: rgba(124, 108, 255, 0.12);
overflow: hidden;
margin-top: 7px;
}
.nc-bar i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--grad);
transition: width 0.4s ease;
}
.node.is-work .nc-bar i {
background: linear-gradient(90deg, #2bb87f, #3ddc97);
}
/* ── Meta ────────────────────────────────────── */
.nc-meta {
display: flex;
justify-content: space-between;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
margin-top: 5px;
font-variant-numeric: tabular-nums;
}
.nc-meta .st {
font-weight: 600;
}
</style>
@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* AlertBar — Status-Übersicht im V2 Dashboard
*
* Props:
* activeCount Agents mit status 'work'
* thinkCount Agents mit status 'think'
* idleCount Agents mit status 'idle'
* blockerCount Blocker-Anzahl
* todayCost Kosten heute (z.B. "$6.40")
* todayTokens Token heute (z.B. "282k")
*/
import { icons } from '../../../composables/icons'
defineProps<{
activeCount: number
thinkCount: number
idleCount: number
blockerCount: number
todayCost: string
todayTokens: string
blockerLabel?: string
}>()
defineEmits<{
blockerClick: []
}>()
</script>
<template>
<div class="alertbar glass-panel">
<!-- Active (arbeitet) -->
<div class="seg">
<span class="dot work"></span>
<span class="seg-label">{{ activeCount }} arbeiten</span>
</div>
<!-- Think (plant) -->
<div class="seg">
<span class="dot think"></span>
<span class="seg-label">{{ thinkCount }} planen</span>
</div>
<!-- Idle (bereit) -->
<div class="seg">
<span class="dot idle"></span>
<span class="seg-label">{{ idleCount }} bereit</span>
</div>
<!-- Separator -->
<div class="sep"></div>
<!-- Kosten heute -->
<div class="seg tx2">
<span class="seg-icon" v-html="icons.coin || ''"></span>
heute <span class="cost-value">{{ todayCost }}</span> · {{ todayTokens }}
</div>
<!-- Blocker Alert (rechts) -->
<button
v-if="blockerCount > 0"
class="blk"
@click="$emit('blockerClick')"
>
<span class="dot block"></span>
{{ blockerLabel || `${blockerCount} Blocker` }}
</button>
</div>
</template>
<style scoped>
.alertbar {
display: flex;
align-items: center;
gap: 14px;
padding: 11px 16px;
border-radius: var(--r);
flex-wrap: wrap;
}
.seg {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
font-weight: 600;
color: var(--tx);
white-space: nowrap;
}
.seg-label {
color: var(--tx-2);
}
.seg-icon :deep(svg) {
width: 14px;
height: 14px;
flex: 0 0 auto;
color: var(--a-mid);
}
.tx2 .seg-icon :deep(svg) {
color: var(--tx-3);
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.55);
animation: pulse-work 1.8s infinite;
}
.dot.think {
background: var(--st-think);
box-shadow: 0 0 0 0 rgba(52,214,245,.55);
animation: pulse-think 1.8s infinite;
}
.dot.idle {
background: var(--st-idle);
}
.dot.block {
background: var(--st-block);
box-shadow: 0 0 0 0 rgba(251,113,133,.55);
animation: pulse-block 1.8s infinite;
}
.sep {
width: 1px;
height: 20px;
background: var(--line-2);
flex: 0 0 auto;
}
.cost-value {
font-family: 'JetBrains Mono', monospace;
font-variant-numeric: tabular-nums;
background: var(--grad);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.blk {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 9px;
padding: 6px 12px;
border-radius: 9px;
background: rgba(251,113,133,.12);
border: 1px solid rgba(251,113,133,.3);
font-size: 12.5px;
font-weight: 600;
color: #fda4b0;
cursor: pointer;
transition: background .15s;
font-family: 'Manrope', sans-serif;
white-space: nowrap;
}
.blk:hover {
background: rgba(251,113,133,.22);
}
@media (max-width: 767px) {
.alertbar {
flex-wrap: wrap;
gap: 8px;
padding: 10px;
}
.seg {
flex: 0 0 calc(50% - 4px);
}
.sep {
display: none;
}
.blk {
margin-left: 0;
}
}
</style>
@@ -0,0 +1,437 @@
<script setup lang="ts">
/**
* FlowCanvas — SVG-Kanten + Auto-Layout + AgentNode-Karten
*
* Props:
* agents Liste der AgentNodeData
* positions Record<id, {x,y}> mit aktuellen Positionen
*
* Emits:
* select Agent ausgewählt (id)
* add Neuen Agent hinzufügen
* updatePositions Positionsänderung
*/
import { computed, onMounted, onUnmounted, ref, nextTick, watch } from 'vue'
import type { AgentNodeData } from '../../../composables/useFlowLayout'
import { autoLayout, buildEdges, curve } from '../../../composables/useFlowLayout'
import { icons } from '../../../composables/icons'
import AgentNode from './AgentNode.vue'
import { useFlowCanvasInteractions } from './useFlowCanvasInteractions'
const props = defineProps<{
agents: AgentNodeData[]
positions: Record<string, { x: number; y: number }>
enteringIds: string[]
}>()
const emit = defineEmits<{
select: [id: string]
add: []
resetLayout: []
updatePositions: [positions: Record<string, { x: number; y: number }>]
}>()
/* ── Refs ───────────────────────────────────────── */
const flowRef = ref<HTMLElement | null>(null)
const svgRef = ref<SVGSVGElement | null>(null)
const edgesDefs = ref('')
const edgesPaths = ref('')
const edgesPulses = ref('')
/* ── Computed ───────────────────────────────────── */
const agentCount = computed(() => props.agents.length)
const autoPositions = computed(() => autoLayout(props.agents))
// Layout label
const layoutLabel = computed(() => {
const n = props.agents.length - 1
if (n <= 0) return `${props.agents.length} Agents`
const maxPerRow = n <= 2 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const rows = Math.ceil(n / maxPerRow)
const hasCustom = Object.keys(props.positions).length > 0
if (hasCustom) {
return `✦ Eigenes Layout · ${props.agents.length} Agents gesamt`
}
return `Layout: ${rows} ${rows === 1 ? 'Reihe' : 'Reihen'} × ${maxPerRow} · ${props.agents.length} Agents gesamt`
})
/* ── Edge Rendering ────────────────────────────── */
function isActive(status: string) {
return status === 'work' || status === 'think'
}
function renderEdges() {
const flow = flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const svg = svgRef.value
if (!svg) return
svg.setAttribute('width', String(fr.width))
svg.setAttribute('height', String(fr.height))
svg.setAttribute('viewBox', `0 0 ${fr.width} ${fr.height}`)
// Node centers in pixel coordinates
function center(id: string): { x: number; y: number } | null {
const el = flow.querySelector(`.node[data-id="${id}"]`) as HTMLElement | null
if (!el) return null
const nr = el.getBoundingClientRect()
return {
x: nr.left - fr.left + nr.width / 2,
y: nr.top - fr.top + nr.height / 2,
}
}
const edgeList = buildEdges(props.agents)
let defs = `<defs><linearGradient id="eg2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#4f7cff"/><stop offset="1" stop-color="#b557f6"/></linearGradient></defs>`
let paths = ''
let pulses = ''
let idCounter = 0
edgeList.forEach((e) => {
const c1 = center(e.a)
const c2 = center(e.b)
if (!c1 || !c2) return
const d = curve(c1, c2)
const a1Status = props.agents.find(a => a.id === e.a)?.status || 'idle'
const a2Status = props.agents.find(a => a.id === e.b)?.status || 'idle'
const live = isActive(a1Status) && isActive(a2Status)
const pathId = `ep${idCounter++}`
if (e.kind === 'flow' && live) {
// Active flow: gradient stroke + animate pulse
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="2.2" opacity="0.85"/>`
paths += `<path d="${d}" fill="none" stroke="#3ddc97" stroke-width="2.2" stroke-dasharray="5 20" opacity="0.8" style="animation:dashmove 1.1s linear infinite"/>`
pulses += `<circle r="3.4" fill="#eafff6"><animateMotion dur="2s" repeatCount="indefinite" rotate="auto"><mpath href="#${pathId}"/></animateMotion></circle>`
} else if (e.kind === 'flow') {
// Inactive flow
paths += `<path id="${pathId}" d="${d}" fill="none" stroke="url(#eg2)" stroke-width="1.8" opacity="0.45"/>`
pulses += `<circle r="2.8" fill="#c9b8ff" opacity="0.7"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#${pathId}"/></animateMotion></circle>`
} else {
// Orchestration (Iris → Agent)
const targetAgent = props.agents.find(a => a.id === e.b)
const op = targetAgent && isActive(targetAgent.status) ? 0.52 : 0.34
paths += `<path d="${d}" fill="none" stroke="#8b7cff" stroke-width="1.45" stroke-dasharray="2 6" opacity="${op}"/>`
}
})
edgesDefs.value = defs
edgesPaths.value = paths
edgesPulses.value = pulses
}
/* ── Resize Observer ──────────────────────────── */
let resizeObserver: ResizeObserver | null = null
function setupObserver() {
if (!flowRef.value) return
resizeObserver = new ResizeObserver(() => {
// Debounce via requestAnimationFrame
if (!debounceRaf) debounceRaf = requestAnimationFrame(() => {
debounceRaf = null
renderEdges()
})
})
resizeObserver.observe(flowRef.value)
}
let debounceRaf: number | null = null
function teardownObserver() {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
if (debounceRaf) {
cancelAnimationFrame(debounceRaf)
debounceRaf = null
}
}
onMounted(() => {
setupObserver()
// Initial render after DOM settles
requestAnimationFrame(() => renderEdges())
})
onUnmounted(() => {
teardownObserver()
})
// Re-render edges when agents or positions change
watch(
() => [props.agents.length, props.positions],
() => {
// Wait for DOM update (AgentNode transitions)
setTimeout(() => renderEdges(), 200)
},
{ deep: true }
)
/* ── Drag & Drop ──────────────────────────────── */
const {
onClick,
onClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
} = useFlowCanvasInteractions({
flowRef,
renderEdges,
updatePositions: positions => emit('updatePositions', positions),
selectAgent: id => emit('select', id),
getPositions: () => props.positions,
})
/* ── Keyboard handler for Enter key on buttons ── */
function handleReset() {
emit('resetLayout')
nextTick(() => renderEdges())
}
</script>
<template>
<div
ref="flowRef"
class="flow"
@click="onClick"
@click.capture="onClickCapture"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<!-- Header -->
<div class="flow-h">
<span class="header-icon" v-html="icons.flow || ''"></span>
<h3>Live-Orchestrierung</h3>
<span class="flow-count">{{ agentCount }} Agents</span>
<button
class="reset-btn"
title="Auto-Layout wiederherstellen"
@click="handleReset"
>
<span class="btn-icon" v-html="icons.flow || ''"></span>
<span class="reset-label">Reset</span>
</button>
<button class="add-btn" @click="emit('add')" title="Agent hinzufügen">
<span class="btn-icon" v-html="icons.plus || ''"></span>
<span class="add-label">Agent hinzufügen</span>
</button>
</div>
<!-- SVG Layer -->
<svg
ref="svgRef"
class="edges"
v-html="edgesDefs + edgesPaths + edgesPulses"
></svg>
<!-- Agent Nodes -->
<AgentNode
v-for="agent in agents"
:key="agent.id"
:agent="agent"
:left="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).x"
:top="(positions[agent.id] || autoPositions[agent.id] || { x: 50, y: 50 }).y"
:entering="enteringIds.includes(agent.id)"
:data-id="agent.id"
@select="(id: string) => emit('select', id)"
/>
<!-- Layout Label -->
<div class="layout-label">{{ layoutLabel }}</div>
</div>
</template>
<style scoped>
.flow {
flex: 1;
position: relative;
border-radius: var(--r);
overflow: hidden;
min-height: 0;
border: 1px solid var(--line);
background:
radial-gradient(120% 90% at 50% 0%, rgba(124, 108, 255, 0.10), transparent 60%);
}
.flow-h {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 4;
display: flex;
align-items: center;
gap: 9px;
padding: 13px 16px;
}
.flow-h h3 {
margin: 0;
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
}
.header-icon :deep(svg) {
width: 18px;
height: 18px;
color: var(--a-mid);
}
.flow-count {
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
font-weight: 600;
padding: 3px 10px;
border-radius: 20px;
background: rgba(124, 108, 255, 0.14);
color: var(--tx-2);
font-variant-numeric: tabular-nums;
}
.reset-btn {
height: 30px;
padding: 0 11px;
border-radius: 9px;
background: rgba(124, 108, 255, 0.1);
border: 1px solid var(--line-2);
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: background 0.15s;
}
.reset-btn:hover {
background: rgba(124, 108, 255, 0.18);
color: var(--tx);
}
.reset-btn .btn-icon :deep(svg) {
width: 13px;
height: 13px;
}
.add-btn {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 14px;
border-radius: 10px;
background: var(--grad);
border: none;
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: var(--glow-purple);
transition: filter 0.15s;
}
.add-btn:hover {
filter: brightness(1.1);
}
.add-btn .btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
/* ── SVG Layer ────────────────────────────────── */
.edges {
position: absolute;
inset: 0;
z-index: 1;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ── Layout Label ─────────────────────────────── */
.layout-label {
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
z-index: 5;
font-family: 'JetBrains Mono', monospace;
font-size: 10.5px;
color: var(--tx-3);
background: rgba(10, 8, 24, 0.7);
padding: 5px 14px;
border-radius: 20px;
border: 1px solid var(--line);
backdrop-filter: blur(8px);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* ── Drag state ───────────────────────────────── */
:deep(.node.dragging) {
cursor: grabbing;
transition: none !important;
z-index: 10;
}
:deep(.node.dragging .ncard) {
box-shadow: 0 0 0 2px var(--a-mid), 0 0 36px -2px rgba(124, 108, 255, 0.9) !important;
transform: scale(1.04);
}
/* Dash animation */
@keyframes dashmove {
to {
stroke-dashoffset: -28;
}
}
/* Node cursor */
:deep(.node) {
cursor: grab;
}
:deep(.node.dragging) {
cursor: grabbing;
}
@media (max-width: 767px) {
.add-label {
display: none;
}
.reset-label {
display: none;
}
.add-btn {
width: 34px;
padding: 0;
display: grid;
place-items: center;
}
.reset-btn {
width: 30px;
padding: 0;
display: grid;
place-items: center;
}
}
</style>
@@ -0,0 +1,318 @@
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { icons } from '../../../composables/icons'
import type { ChatMessage } from './types'
const props = defineProps<{
messages: ChatMessage[]
isThinking: boolean
error?: string | null
}>()
const emit = defineEmits<{
send: [text: string]
}>()
const inputText = ref('')
const scrollEl = ref<HTMLElement | null>(null)
function handleSend() {
const text = inputText.value.trim()
if (!text) return
emit('send', text)
inputText.value = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
watch(
() => props.messages.length,
() => {
nextTick(() => {
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
})
}
)
</script>
<template>
<section class="iris-panel">
<!-- Header -->
<div class="iris-head">
<div class="iris-av" v-html="icons.bot || ''"></div>
<div>
<div class="iris-name">Iris</div>
<div class="iris-sub">Chief of Staff · <span class="online">online</span></div>
</div>
<button class="expand-btn" type="button" v-html="icons.expand || ''"></button>
</div>
<!-- Chat Scroll -->
<div ref="scrollEl" class="chat-scroll">
<div v-if="error" class="chat-msg-info error"> {{ error }}</div>
<div v-else-if="!messages.length && !isThinking" class="chat-msg-info">Noch keine Nachrichten.</div>
<div v-for="(msg, i) in messages" :key="i" class="chat-row">
<template v-if="msg.sender === 'iris'">
<div class="bubble iris">{{ msg.text }}</div>
<div v-if="msg.tool" class="tool">
<span v-html="icons.doc || ''"></span>{{ msg.tool }}
</div>
</template>
<div v-else class="bubble me">{{ msg.text }}</div>
</div>
<div v-if="isThinking" class="chat-row">
<div class="bubble iris"><span class="caret"></span></div>
</div>
</div>
<!-- Input -->
<div class="chat-in">
<input
v-model="inputText"
type="text"
placeholder="Nachricht an Iris…"
@keydown="onKeydown"
/>
<button class="send" type="button" @click="handleSend" v-html="icons.send || ''"></button>
</div>
</section>
</template>
<style scoped>
.iris-panel {
width: var(--rail-w, 360px);
flex: 0 0 var(--rail-w, 360px);
display: flex;
flex-direction: column;
min-height: 0;
background: linear-gradient(180deg, rgba(20,17,48,.6), rgba(12,10,30,.6));
border: 1px solid var(--line);
border-radius: var(--r);
backdrop-filter: blur(12px);
overflow: hidden;
}
/* ── Header ─────────────────────────────────── */
.iris-head {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
.iris-av {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--grad);
display: grid;
place-items: center;
box-shadow: var(--glow-purple);
flex: 0 0 auto;
}
.iris-av :deep(svg) {
width: 18px;
height: 18px;
color: #fff;
}
.iris-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 600;
font-size: 14.5px;
color: var(--tx);
line-height: 1.2;
}
.iris-sub {
font-size: 11px;
color: var(--tx-3);
margin-top: 1px;
}
.online {
color: var(--st-work);
}
.expand-btn {
margin-left: auto;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s, color .15s;
flex: 0 0 auto;
}
.expand-btn:hover {
background: rgba(124,108,255,.10);
color: var(--tx);
}
.expand-btn :deep(svg) {
width: 16px;
height: 16px;
}
/* ── Messages ────────────────────────────────── */
.chat-scroll {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.chat-scroll::-webkit-scrollbar { width: 6px; }
.chat-scroll::-webkit-scrollbar-thumb {
background: rgba(124,108,255,.22);
border-radius: 6px;
}
.chat-scroll::-webkit-scrollbar-track { background: transparent; }
.chat-msg-info {
font-family: 'Manrope', sans-serif;
font-size: 12px;
color: var(--tx-3);
font-style: italic;
text-align: center;
padding: 24px 0;
}
.chat-msg-info.error { color: #fda4b0; font-style: normal; }
.chat-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.bubble {
max-width: 84%;
padding: 10px 13px;
border-radius: 14px;
font-family: 'Manrope', sans-serif;
font-size: 13px;
line-height: 1.5;
}
.bubble.iris {
background: rgba(124,108,255,.12);
border: 1px solid var(--line-2);
border-bottom-left-radius: 5px;
color: var(--tx);
}
.bubble.me {
background: var(--grad);
color: #fff;
border-bottom-right-radius: 5px;
margin-left: auto;
box-shadow: var(--glow-purple);
}
.tool {
display: flex;
align-items: center;
gap: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--st-think);
padding-left: 4px;
}
.tool :deep(svg) {
width: 12px;
height: 12px;
}
.caret::after {
content: '▍';
animation: blink 1s steps(1) infinite;
color: var(--st-think);
}
@keyframes blink { 50% { opacity: 0; } }
@media (max-width: 767px) {
.iris-panel {
width: 100%;
flex: 0 0 auto;
max-height: 45vh;
}
.chat-scroll {
max-height: 30vh;
}
.expand-btn {
display: none;
}
}
/* ── Input ───────────────────────────────────── */
.chat-in {
padding: 12px;
border-top: 1px solid var(--line);
display: flex;
gap: 9px;
align-items: center;
flex: 0 0 auto;
}
.chat-in input {
flex: 1;
height: 40px;
border-radius: 11px;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.06);
color: var(--tx);
padding: 0 14px;
font-family: 'Manrope', sans-serif;
font-size: 13px;
outline: none;
transition: border-color .15s;
}
.chat-in input::placeholder { color: var(--tx-3); }
.chat-in input:focus { border-color: var(--line-3); }
.send {
width: 40px;
height: 40px;
border-radius: 11px;
border: none;
background: var(--grad);
display: grid;
place-items: center;
cursor: pointer;
box-shadow: var(--glow-purple);
flex: 0 0 auto;
transition: filter .15s;
}
.send:hover { filter: brightness(1.1); }
.send :deep(svg) {
width: 17px;
height: 17px;
color: #fff;
}
</style>
@@ -0,0 +1,165 @@
<script setup lang="ts">
import type { TaskItem } from './types'
defineProps<{
tasks: TaskItem[]
loading?: boolean
error?: string | null
}>()
function prioLabel(p: TaskItem['priority']): string {
return p === 'high' ? 'P0' : p === 'medium' ? 'P1' : 'P2'
}
function prioColor(p: TaskItem['priority']): string {
return p === 'high' ? '#fda4b0' : p === 'medium' ? '#fcd34d' : '#9db6ff'
}
function dotClass(s: TaskItem['status']): string {
return s === 'active' ? 'work' : s === 'blocked' ? 'block' : 'queue'
}
function statusLabel(s: TaskItem['status']): string {
return s === 'active' ? 'Läuft' : s === 'blocked' ? 'Blocker' : 'Queue'
}
</script>
<template>
<div class="tstrip">
<template v-if="loading">
<div v-for="n in 4" :key="n" class="tcard skeleton"></div>
</template>
<div v-else-if="error" class="tstrip-msg"> {{ error }}</div>
<div v-else-if="!tasks.length" class="tstrip-msg">Keine aktiven Tasks</div>
<template v-else>
<div
v-for="task in tasks.slice(0, 4)"
:key="task.id"
class="tcard"
:class="{ block: task.status === 'blocked' }"
>
<div class="tcard-row">
<span class="pr" :style="{ background: 'rgba(124,108,255,.14)', color: prioColor(task.priority) }">
{{ prioLabel(task.priority) }}
</span>
<span class="dot" :class="dotClass(task.status)"></span>
<span class="stl">{{ statusLabel(task.status) }}</span>
</div>
<div class="tt">{{ task.title }}</div>
<div class="ow">{{ task.agent }}</div>
</div>
</template>
</div>
</template>
<style scoped>
.tstrip {
display: flex;
gap: 10px;
overflow: hidden;
flex: 0 0 auto;
}
.tcard {
flex: 1;
min-width: 0;
padding: 11px 13px;
border-radius: 12px;
background: var(--glass);
border: 1px solid var(--line);
display: flex;
flex-direction: column;
}
.tcard.block {
border-color: rgba(251,113,133,.35);
background: rgba(251,113,133,.07);
}
.tcard-row {
display: flex;
align-items: center;
gap: 7px;
}
.pr {
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: 5px;
flex: 0 0 auto;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.dot.work { background: var(--st-work); animation: pulse-work 1.8s infinite; }
.dot.queue { background: var(--st-queue); }
.dot.block { background: var(--st-block); animation: pulse-block 1.4s infinite; }
.dot.idle { background: var(--st-idle); }
.stl {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
color: var(--tx-3);
}
.tt {
font-size: 12px;
font-weight: 600;
margin-top: 7px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--tx);
}
.ow {
font-size: 10.5px;
color: var(--tx-3);
margin-top: 5px;
}
.skeleton {
height: 78px;
background: var(--glass);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.8; }
}
.tstrip-msg {
font-family: 'Manrope', sans-serif;
font-size: 11px;
color: var(--tx-3);
padding: 12px;
white-space: nowrap;
}
@media (max-width: 767px) {
.tstrip {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tstrip::-webkit-scrollbar {
display: none;
}
.tcard {
flex: 0 0 200px;
}
}
</style>
@@ -0,0 +1,63 @@
/**
* Shared types for V2 Dashboard components
*/
export interface ChatMessage {
sender: 'iris' | 'user'
text: string
ts: string
tool?: string
}
export interface TaskItem {
id: string
title: string
agent: string
priority: 'high' | 'medium' | 'low'
status: 'active' | 'pending' | 'blocked'
progress: number // 0100
detail?: string | null
source?: string
}
/* ── Agent Detail Modal Types ─────────────────── */
export interface ThinkingItem {
type: 'thought' | 'action' | 'result'
text: string
ts: string
}
export interface AgentActivityItem {
time: string
text: string
}
/** Dashboard view-model for an agent detail modal */
export interface AgentDetailData {
id: string
name: string
role: string
roleBadge: string
model: string
status: 'work' | 'think' | 'idle'
statusLabel: string
task: string | null
goal: string | null
progress: number
elapsed: string
next: string
tokens: string
cost: string
think: string | null
md?: string
tokensToday: number
costToday: number
workload: number
uptime: string
lastActive: string
activeTaskCount: number
thinking: ThinkingItem[]
activity: AgentActivityItem[]
availableModels: { id: string; alias: string }[]
}
@@ -0,0 +1,135 @@
import { ref } from 'vue'
const DRAG_THRESHOLD = 5
const CLICK_SUPPRESSION_MS = 400
export interface FlowPosition {
x: number
y: number
}
interface DragState {
id: string
startX: number
startY: number
ox: number
oy: number
moved: boolean
raf: number | null
}
interface UseFlowCanvasInteractionsOptions {
flowRef: { value: HTMLElement | null }
renderEdges: () => void
updatePositions: (positions: Record<string, FlowPosition>) => void
selectAgent: (id: string) => void
getPositions: () => Record<string, FlowPosition>
}
function findNode(target: EventTarget | null) {
return (target as HTMLElement | null)?.closest('.node') as HTMLElement | null
}
export function useFlowCanvasInteractions(options: UseFlowCanvasInteractionsOptions) {
const drag = ref<DragState | null>(null)
const suppressClickUntil = ref(0)
function onPointerDown(e: PointerEvent) {
const node = findNode(e.target)
if (!node) return
e.preventDefault()
const nr = node.getBoundingClientRect()
drag.value = {
id: node.dataset.id || '',
startX: e.clientX,
startY: e.clientY,
ox: e.clientX - (nr.left + nr.width / 2),
oy: e.clientY - (nr.top + nr.height / 2),
moved: false,
raf: null,
}
node.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!drag.value) return
const currentDrag = drag.value
const dist = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
if (!currentDrag.moved && dist < DRAG_THRESHOLD) return
if (!currentDrag.moved) {
currentDrag.moved = true
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) node.classList.add('dragging')
}
const flow = options.flowRef.value
if (!flow) return
const fr = flow.getBoundingClientRect()
const x = Math.max(8, Math.min(92, ((e.clientX - currentDrag.ox - fr.left) / fr.width) * 100))
const y = Math.max(10, Math.min(92, ((e.clientY - currentDrag.oy - fr.top) / fr.height) * 100))
const node = flow.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) {
node.style.left = x + '%'
node.style.top = y + '%'
}
options.updatePositions({
...options.getPositions(),
[currentDrag.id]: { x, y },
})
if (!currentDrag.raf) {
currentDrag.raf = requestAnimationFrame(() => {
options.renderEdges()
if (drag.value) drag.value.raf = null
})
}
}
function onPointerUp(e: PointerEvent) {
if (!drag.value) return
const currentDrag = drag.value
const endDistance = Math.hypot(e.clientX - currentDrag.startX, e.clientY - currentDrag.startY)
const wasDragged = currentDrag.moved || endDistance >= DRAG_THRESHOLD
const node = options.flowRef.value?.querySelector(`.node[data-id="${currentDrag.id}"]`) as HTMLElement | null
if (node) node.classList.remove('dragging')
if (wasDragged) {
suppressClickUntil.value = performance.now() + CLICK_SUPPRESSION_MS
}
drag.value = null
}
function onClick(e: MouseEvent) {
const node = findNode(e.target)
if (!node) return
if (performance.now() < suppressClickUntil.value) return
const id = node.dataset.id
if (id) options.selectAgent(id)
}
function onClickCapture(e: MouseEvent) {
if (performance.now() >= suppressClickUntil.value) return
if (!findNode(e.target)) return
e.preventDefault()
e.stopPropagation()
}
return {
onClick,
onClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
}
}
+6 -6
View File
@@ -36,8 +36,8 @@ defineEmits<{
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 20px; padding: 10px 20px;
border-bottom: 1px solid var(--line, #1f2330); border-bottom: 1px solid var(--nx-line, #1f2330);
background: var(--panel, #11141b); background: var(--nx-panel, #11141b);
} }
.mobile-menu { display: none; } .mobile-menu { display: none; }
.search { .search {
@@ -46,9 +46,9 @@ defineEmits<{
gap: 8px; gap: 8px;
flex: 1; flex: 1;
padding: 6px 12px; padding: 6px 12px;
border: 1px solid var(--line, #1f2330); border: 1px solid var(--nx-line, #1f2330);
border-radius: 7px; border-radius: 7px;
color: var(--text-dim, #6f7889); color: var(--nx-text-dim, #6f7889);
font-size: 11px; font-size: 11px;
} }
.search kbd { .search kbd {
@@ -82,13 +82,13 @@ defineEmits<{
padding: 5px 10px; padding: 5px 10px;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
background: var(--accent, #7b6ef2); background: var(--nx-accent, #7b6ef2);
color: #fff; color: #fff;
font-size: 10px; font-size: 10px;
cursor: pointer; cursor: pointer;
} }
@media (max-width: 860px) { @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> </style>
@@ -3,11 +3,12 @@ import { computed } from 'vue'
import { import {
Activity, Bot, Boxes, Command, FileText, Activity, Bot, Boxes, Command, FileText,
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings, LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
Shield, SlidersHorizontal, Sparkles, Users, BookOpen, Shield, SlidersHorizontal, Sparkles, BookOpen,
AlertTriangle, Calendar, AlertTriangle, Calendar,
} from '@lucide/vue' } from '@lucide/vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { initials } from '../../utils/format'
const props = defineProps<{ const props = defineProps<{
activeView: string activeView: string
@@ -23,13 +24,14 @@ const emit = defineEmits<{
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const ownerInitials = computed(() => auth.user?.displayName.split(' ').map(part => part[0]).join('').slice(0, 2).toUpperCase() ?? 'OW') const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
const navigation = [ const navigation = [
{ label: 'Dashboard', icon: LayoutDashboard }, { label: 'Dashboard', icon: LayoutDashboard },
{ label: 'Memory', icon: FileText }, { label: 'Memory', icon: FileText },
{ label: 'Docs', icon: BookOpen }, { label: 'Docs', icon: BookOpen },
{ label: 'Team', icon: Users },
{ label: 'Security', icon: Shield }, { label: 'Security', icon: Shield },
{ label: 'Projects', icon: Boxes }, { label: 'Projects', icon: Boxes },
{ label: 'Task Board', icon: ListTodo }, { label: 'Task Board', icon: ListTodo },
@@ -155,9 +157,9 @@ async function logout() {
.nav-separator { .nav-separator {
height: 1px; height: 1px;
margin: 6px 10px; 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 { .sidebar-bottom > button {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -172,8 +174,8 @@ async function logout() {
cursor: pointer; cursor: pointer;
transition: background .15s, color .15s; transition: background .15s, color .15s;
} }
.sidebar-bottom > button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; } .sidebar-bottom > button:hover { background: var(--nx-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.active { background: var(--nx-accent-soft, rgba(123,110,242,.08)); color: var(--nx-accent, #7b6ef2); font-weight: 600; }
.owner { .owner {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { NavItemDef } from '../../composables/icons'
import NavItem from './NavItem.vue'
defineProps<{
label: string
items: NavItemDef[]
}>()
</script>
<template>
<div class="nav-group">
<div class="nav-group-label">{{ label }}</div>
<NavItem
v-for="item in items"
:key="item.label"
:icon="item.icon"
:label="item.label"
:route="item.route"
:count="item.count"
:active="item.active"
/>
</div>
</template>
<style scoped>
.nav-group-label {
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--tx-3);
font-weight: 700;
padding: 16px 10px 7px;
font-family: 'Manrope', sans-serif;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { icons } from '../../composables/icons'
const props = defineProps<{
icon: string
label: string
route?: string
count?: string
active?: boolean
}>()
const router = useRouter()
const route = useRoute()
const isActive = computed(() => {
if (props.active) return true
if (props.route && route.path === props.route) return true
return false
})
function navigate() {
if (props.route) {
router.push(props.route)
}
}
</script>
<template>
<button
:class="['nav-item', { active: isActive }]"
@click="navigate"
>
<!-- Icon -->
<span class="nav-icon" v-html="icons[icon] || ''"></span>
<!-- Label -->
<span class="nav-label">{{ label }}</span>
<!-- Count badge -->
<span v-if="count !== undefined" class="count">{{ count }}</span>
</button>
</template>
<style scoped>
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 11px;
border-radius: 10px;
border: none;
background: transparent;
color: var(--tx-2);
font-family: 'Manrope', sans-serif;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
position: relative;
transition: background .16s, color .16s;
text-decoration: none;
width: 100%;
text-align: left;
}
.nav-item:hover {
background: rgba(124,108,255,.08);
color: var(--tx);
}
.nav-item.active {
color: #fff;
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
border-radius: 3px;
background: var(--grad);
box-shadow: var(--glow-purple);
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 17px;
height: 17px;
flex: 0 0 auto;
opacity: .85;
}
.nav-icon :deep(svg) {
width: 17px;
height: 17px;
}
.nav-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.count {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 20px;
background: rgba(124,108,255,.16);
color: var(--tx);
line-height: 1.4;
flex-shrink: 0;
}
</style>
+264
View File
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { useAgentStore } from '../../stores/agents'
import { useTaskStore } from '../../stores/tasks'
import { navigation, icons } from '../../composables/icons'
import type { NavGroupDef } from '../../composables/icons'
defineProps<{
mobileOpen?: boolean
}>()
defineEmits<{
close: []
}>()
import NavGroup from './NavGroup.vue'
import { initials } from '../../utils/format'
const auth = useAuthStore()
const router = useRouter()
const agentStore = useAgentStore()
const taskStore = useTaskStore()
const ownerInitials = computed(() =>
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
)
function logout() {
auth.logout()
router.replace('/login')
}
/**
* Dynamische Nav-Item-Counts aus den Stores.
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
*/
const dynamicNavigation = computed<NavGroupDef[]>(() => {
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
return navigation.map(group => ({
...group,
items: group.items.map(item => {
let dynamicCount: string | undefined
switch (item.label) {
case 'Agenten':
case 'Hosts · OpenClaw':
dynamicCount = String(agentStore.agentList.length)
break
case 'Task Board':
dynamicCount = String(taskStore.taskList.length)
break
case 'Kosten & Tokens':
dynamicCount = agentStore.todayCost
break
case 'Docs & .md':
dynamicCount = '0'
break
case 'Incidents':
dynamicCount = '0'
break
}
return {
...item,
count: dynamicCount ?? item.count,
}
}),
}))
})
</script>
<template>
<aside :class="['sidebar', { open: mobileOpen }]">
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
<!-- Brand -->
<div class="side-top">
<div class="brand-mark" v-html="icons.command || ''"></div>
<div>
<div class="brand-name">NEXUS</div>
<div class="brand-sub">Mission Control</div>
</div>
</div>
<!-- Navigation -->
<nav class="nav">
<NavGroup
v-for="(group, idx) in dynamicNavigation"
:key="idx"
:label="group.group"
:items="group.items"
/>
</nav>
<!-- Footer -->
<div class="side-foot">
<div class="avatar">{{ ownerInitials }}</div>
<div class="owner-info">
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
</div>
</div>
</aside>
</template>
<style scoped>
.sidebar {
width: 248px;
flex: 0 0 248px;
height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
border-right: 1px solid var(--line);
backdrop-filter: blur(14px);
padding: 0;
position: relative;
z-index: 2;
}
.side-top {
display: flex;
align-items: center;
gap: 11px;
padding: 18px 18px 16px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 11px;
display: grid;
place-items: center;
background: var(--grad);
box-shadow: var(--glow-purple);
flex: 0 0 auto;
}
.brand-mark :deep(svg) {
width: 20px;
height: 20px;
color: #fff;
}
.brand-name {
font-family: 'Space Grotesk', sans-serif;
font-weight: 700;
font-size: 17px;
letter-spacing: .14em;
line-height: 1;
}
.brand-sub {
font-size: 10.5px;
color: var(--tx-3);
letter-spacing: .05em;
margin-top: 3px;
}
.nav {
flex: 1;
overflow-y: auto;
padding: 6px 12px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.side-foot {
padding: 12px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
transition: background .15s;
}
.side-foot:hover {
background: rgba(124,108,255,.06);
}
.sidebar-close {
display: none;
}
@media (max-width: 767px) {
.sidebar {
position: fixed;
left: 0;
top: 0;
z-index: 100;
height: 100vh;
width: 280px;
transform: translateX(-100%);
transition: transform 0.25s ease;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-close {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 18px;
right: 12px;
width: 30px;
height: 30px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
z-index: 1;
}
.sidebar-close:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.sidebar-close :deep(svg) {
width: 18px;
height: 18px;
}
}
.avatar {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--grad-soft);
border: 1px solid var(--line-2);
display: grid;
place-items: center;
font-weight: 700;
font-size: 13px;
color: var(--tx);
flex-shrink: 0;
}
.owner-info {
min-width: 0;
}
.owner-name {
font-size: 12px;
font-weight: 600;
color: var(--tx);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.owner-role {
font-size: 10px;
color: var(--tx-3);
margin-top: 1px;
text-transform: capitalize;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { icons } from '../../composables/icons'
defineProps<{
connected?: boolean
statusLabel?: string
}>()
defineEmits<{
'toggle-sidebar': []
}>()
</script>
<template>
<header class="topbar">
<!-- Hamburger (mobile only) -->
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
<!-- Search -->
<div class="search">
<span class="search-icon" v-html="icons.search || ''"></span>
<span class="search-placeholder">Operationen, Agents oder Tasks suchen</span>
</div>
<!-- Spacer -->
<div class="spacer"></div>
<!-- Status Pill -->
<span :class="['pill', connected ? 'live' : 'preview']">
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
</span>
<!-- Ask Iris Button -->
<button class="btn btn-primary ask-iris-btn">
<span class="btn-icon" v-html="icons.spark || ''"></span>
<span class="ask-label">Ask Iris</span>
</button>
</header>
</template>
<style scoped>
.topbar {
height: 62px;
flex: 0 0 62px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: rgba(8,6,20,.5);
backdrop-filter: blur(14px);
}
.search {
flex: 1;
max-width: 560px;
display: flex;
align-items: center;
gap: 10px;
height: 38px;
padding: 0 14px;
border-radius: 11px;
background: rgba(124,108,255,.06);
border: 1px solid var(--line);
color: var(--tx-3);
font-size: 13.5px;
font-family: 'Manrope', sans-serif;
}
.search-icon :deep(svg) {
width: 16px;
height: 16px;
flex: 0 0 auto;
}
.search-placeholder {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.spacer {
flex: 1;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 11px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 600;
font-family: 'Manrope', sans-serif;
border: 1px solid var(--line-2);
background: rgba(124,108,255,.07);
color: var(--tx-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.status-dot.on {
background: var(--st-work);
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
animation: pulse-work 1.8s infinite;
}
.status-dot.off {
background: var(--st-idle);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
font-family: 'Manrope', sans-serif;
font-weight: 600;
font-size: 13px;
cursor: pointer;
border: none;
transition: filter .16s;
}
.btn-primary {
background: var(--grad);
color: #fff;
box-shadow: var(--glow-purple);
}
.btn-primary:hover {
filter: brightness(1.08);
}
.btn-icon :deep(svg) {
width: 15px;
height: 15px;
}
.hamburger {
display: none;
}
@media (max-width: 767px) {
.topbar {
padding: 0 14px;
}
.search {
flex: 1;
max-width: none;
}
.hamburger {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--tx-2);
cursor: pointer;
flex: 0 0 auto;
}
.hamburger:hover {
background: rgba(124,108,255,.1);
color: var(--tx);
}
.hamburger :deep(svg) {
width: 20px;
height: 20px;
}
.pill {
display: none;
}
.ask-iris-btn {
width: 32px;
height: 32px;
padding: 0;
display: grid;
place-items: center;
border-radius: 9px;
flex: 0 0 auto;
}
.ask-label {
display: none;
}
.ask-iris-btn .btn-icon {
display: flex;
}
}
</style>
@@ -0,0 +1,417 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import AgentCard from './AgentCard.vue'
interface AgentData {
id: string
name: string
role: string
description: string
tags: string[]
color: string
icon: string
hero?: boolean
}
const props = defineProps<{
agents: AgentData[]
heroId?: string
activeAgents?: string[]
}>()
const emit = defineEmits<{
select: [id: string]
}>()
// ── Layout refs ──
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))
function isActive(id: string): boolean {
return props.activeAgents?.includes(id) ?? false
}
// ── 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
}
// ── SVG path computation ──
interface ConnectionPath {
d: string
length: number
}
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
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.30 + t * 0.40)
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 + 40
const cp2x = endX + (isLeftColumn ? 50 : -50)
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
// Track path elements and animation offset per agent
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
}
}
/** Refresh path lengths and pulse dasharrays from current SVG elements */
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 // full traversal in ~3s
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)
}
})
onUnmounted(() => {
stopPulseAnimation()
resizeObserver?.disconnect()
})
</script>
<template>
<div ref="networkRef" class="team-network">
<!-- SVG Connection Layer -->
<svg
v-if="svgWidth > 0 && svgHeight > 0"
class="network-svg"
:width="svgWidth"
:height="svgHeight"
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
v-for="agent in childAgents"
:key="`glow-${agent.id}`"
:id="`glow-${agent.id}`"
x="-30%" y="-30%" width="160%" height="160%"
>
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Connection lines for each agent -->
<template v-for="agent in childAgents" :key="agent.id">
<!-- Base line -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePathRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
:stroke-width="isActive(agent.id) ? 2.5 : 1.5"
fill="none"
:opacity="isActive(agent.id) ? 0.7 : 0.25"
stroke-linecap="round"
/>
<!-- Glow line for active agent -->
<path
v-if="isActive(agent.id) && connectionPaths[agent.id]"
:d="connectionPaths[agent.id]!.d"
:stroke="agent.color"
stroke-width="4"
fill="none"
stroke-linecap="round"
:filter="`url(#glow-${agent.id})`"
opacity="0.5"
/>
<!-- Pulse line (white dashed segment moving along) -->
<path
v-if="connectionPaths[agent.id]"
:ref="storePulseRef(agent.id)"
:d="connectionPaths[agent.id]!.d"
stroke="white"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:opacity="isActive(agent.id) ? 1 : 0.4"
/>
</template>
</svg>
<!-- Cards Layer (above SVG) -->
<div class="cards-layer">
<!-- Hero: Iris centered top -->
<div class="hero-slot" data-agent-id="iris">
<AgentCard
v-bind="hero"
:class="{ 'hero-active': isActive(hero.id) }"
:style="{
boxShadow: isActive(hero.id)
? `0 0 20px ${hero.color}44`
: undefined,
borderColor: isActive(hero.id) ? hero.color : undefined,
}"
@click="emit('select', hero.id)"
/>
</div>
<!-- Agent Grid: 2 columns x 2 rows -->
<div class="agent-grid">
<div
v-for="agent in childAgents"
:key="agent.id"
:data-agent-id="agent.id"
class="agent-slot"
>
<AgentCard
v-bind="agent"
:style="{
boxShadow: isActive(agent.id)
? `0 0 14px ${agent.color}55, 0 0 30px ${agent.color}22`
: undefined,
borderColor: isActive(agent.id) ? agent.color : undefined,
}"
@click="emit('select', agent.id)"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.team-network {
position: relative;
width: 100%;
}
.network-svg {
position: absolute;
top: 0;
left: 0;
z-index: 0;
pointer-events: none;
overflow: visible;
}
.cards-layer {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
}
.hero-slot {
width: 100%;
max-width: 520px;
transition: border-color 0.3s, box-shadow 0.3s;
}
.hero-active {
transition: border-color 0.3s, box-shadow 0.3s;
}
.agent-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
width: 100%;
max-width: 820px;
}
.agent-slot {
width: 100%;
transition: border-color 0.3s, box-shadow 0.3s;
}
@media (max-width: 720px) {
.agent-grid {
grid-template-columns: 1fr;
}
.cards-layer {
gap: 20px;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More