Compare commits
60 Commits
agent/hex/
...
agent/Dex/
| Author | SHA1 | Date | |
|---|---|---|---|
| e4e6115bf8 | |||
| 17a28a33e1 | |||
| 9a802b4212 | |||
| 1a50306f7d | |||
| e8ced74429 | |||
| 8b8cb8210c | |||
| 4a2e660a4a | |||
| 07d40d729f | |||
| 8da593c450 | |||
| 437a519c36 | |||
| c906cd46ad | |||
| cce3e061a7 | |||
| ab19a7ccde | |||
| 745994182f | |||
| 1775c25b61 | |||
| 999f6614ce | |||
| 048101e85c | |||
| dcfa4dc2a2 | |||
| 679d65ccea | |||
| e84a479e33 | |||
|
|
7223a2745f | ||
| 8331468b44 | |||
| e5d9b7ea07 | |||
| 0108d8aca0 | |||
| 27d877db6c | |||
| 29dd9321f8 | |||
|
|
64adffa0b4 | ||
|
|
3a0efaa5a4 | ||
| 2a99ace9f8 | |||
| 0ddffaf266 | |||
| 55fd2cd0d2 | |||
| 44b4758747 | |||
| 45b9068acc | |||
| e39fd45018 | |||
| 5fd37b556a | |||
| a2567dd3aa | |||
| 512a3364cf | |||
| bca3bf7677 | |||
| 1c012de47b | |||
| ea603c3552 | |||
| bcaf85c369 | |||
| 84e3d5420e | |||
| 8bdbcae13a | |||
| db91c8bde9 | |||
| e2582569b0 | |||
| 53454e0635 | |||
| d06caeab8e | |||
| 49a9a95086 | |||
| 2a21cad431 | |||
| 5375d11792 | |||
| ed1ee886db | |||
|
|
c8ca182af0 | ||
|
|
fb88eab4d1 | ||
|
|
82c12554d0 | ||
|
|
f170def0ea | ||
|
|
d2da0c160f | ||
|
|
bcaa526a69 | ||
|
|
14b3dab88b | ||
|
|
b4e110f4c3 | ||
|
|
d5a85c4ed0 |
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# =============================================================================
|
||||
# Control Center — Environment Configuration Template
|
||||
# =============================================================================
|
||||
# Copy this file to `.env` and fill in real values.
|
||||
# Never commit `.env` — it is in .gitignore by default.
|
||||
# =============================================================================
|
||||
|
||||
# ── Database ────────────────────────────────────────────────────────────────
|
||||
POSTGRES_DB=controlcenter
|
||||
POSTGRES_USER=controlcenter
|
||||
POSTGRES_PASSWORD=changeme
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# ── Backend ───────────────────────────────────────────────────────────────────
|
||||
BACKEND_PORT=8080
|
||||
LOG_LEVEL=info
|
||||
ENVIRONMENT=development
|
||||
|
||||
# ── Frontend ────────────────────────────────────────────────────────────────
|
||||
FRONTEND_PORT=3000
|
||||
|
||||
# ── CORS ────────────────────────────────────────────────────────────────────
|
||||
# Comma-separated allowed origins. Use "*" for local dev.
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# ── OpenClaw Gateway ──────────────────────────────────────────────────────────
|
||||
# URL to the OpenClaw gateway agent status endpoint.
|
||||
# In Docker Compose, use the container name or host.docker.internal for the
|
||||
# gateway running outside Compose.
|
||||
GATEWAY_URL=http://host.docker.internal:18789/api/agents
|
||||
|
||||
# How often to poll the gateway for agent state updates.
|
||||
GATEWAY_POLL_INTERVAL=5s
|
||||
40
.gitea/workflows/dev.yml
Normal file
40
.gitea/workflows/dev.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Dev Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [dev]
|
||||
push:
|
||||
branches: [dev]
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
|
||||
- name: Build go-backend
|
||||
run: go build ./cmd/...
|
||||
working-directory: ./go-backend
|
||||
|
||||
- name: Test go-backend
|
||||
run: go test ./...
|
||||
working-directory: ./go-backend
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: ./frontend
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build
|
||||
working-directory: ./frontend
|
||||
223
README.md
223
README.md
@@ -0,0 +1,223 @@
|
||||
# Control Center
|
||||
|
||||
> Real-time agent fleet dashboard for the OpenClaw AI development team.
|
||||
|
||||
Control Center monitors and controls the full CubeCraft Creations AI agent fleet — Otto, Rex, Dex, Hex, Pip, Nano, Sketch, Bob, Stuart, Norbert, and Flip. It provides live agent status, task progress, session logs, and a command interface, backed by SignalR for push-based updates directly from the OpenClaw gateway.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | ASP.NET Core Web API (.NET 8) |
|
||||
| Database | PostgreSQL (snake_case via EF Core) |
|
||||
| ORM | Entity Framework Core |
|
||||
| Real-time | SignalR (`AgentStatusHub`) |
|
||||
| Frontend | Angular (latest), Angular Material, Angular Signals |
|
||||
| API Client | TypeScript package (`api-client/`) |
|
||||
| Deployment | Docker |
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Control-Center/
|
||||
├── backend/
|
||||
│ ├── ControlCenter/
|
||||
│ │ ├── Controllers/ # AgentsController, CommandController, LogsController
|
||||
│ │ ├── Hubs/ # AgentStatusHub, IAgentStatusClient, AgentStatusModels
|
||||
│ │ ├── Models/ # AgentMinionMapping
|
||||
│ │ ├── Services/ # AgentMinionMapperService, GatewayEventBridgeService
|
||||
│ │ ├── Program.cs
|
||||
│ │ └── appsettings.json
|
||||
│ ├── Configurations/ # AgentConfiguration
|
||||
│ ├── Data/ # AppDbContext, AppDbContextFactory
|
||||
│ ├── Dtos/ # AgentStatusUpdateDto
|
||||
│ ├── Entities/ # Agent, AgentStatus
|
||||
│ ├── Migrations/ # EF migrations
|
||||
│ ├── Models/ # AgentState
|
||||
│ └── Repositories/ # AgentStateRepository, IAgentStateRepository
|
||||
├── frontend/
|
||||
│ └── src/app/
|
||||
│ ├── layout/ # LayoutShell, HeaderBar, NavRail, BottomNav
|
||||
│ ├── pages/ # hub, logs, projects, sessions, settings
|
||||
│ ├── components/ # AgentCard, AgentStatusBadge, TaskProgressBar,
|
||||
│ │ # QuickJumpButton, QuickJumpDrawer,
|
||||
│ │ # GlobalActionModal, AdaptiveNavigation
|
||||
│ ├── command-hub/ # AgentCard command hub view
|
||||
│ ├── models/ # Agent model, AgentStatus, AgentCardData, Nav
|
||||
│ └── services/ # AgentStatusService (Angular Signals)
|
||||
├── api-client/ # Shared TS client (models, SignalR WS, HTTP utils)
|
||||
├── design/
|
||||
│ ├── command-hub-spec.md
|
||||
│ └── mockups/ # Desktop kiosk, mobile, quick-jump drawer
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Agent
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `Id` | `Guid` | Primary key |
|
||||
| `Status` | `AgentStatus` | Current agent state |
|
||||
| `Task` | `string?` | Active task description |
|
||||
| `Progress` | `int?` | Task completion % (0–100) |
|
||||
| `SessionKey` | `string` | OpenClaw session identifier |
|
||||
| `Channel` | `string` | Source channel (telegram, slack, etc.) |
|
||||
| `LastActivity` | `DateTime` | Last event timestamp |
|
||||
| `CreatedAt` / `UpdatedAt` | `DateTime` | Audit fields |
|
||||
|
||||
### AgentStatus enum
|
||||
|
||||
| Value | Integer | Meaning |
|
||||
|---|---|---|
|
||||
| `Active` | 0 | Agent is executing a task |
|
||||
| `Idle` | 1 | Agent is waiting for work |
|
||||
| `Thinking` | 2 | Agent is reasoning/planning |
|
||||
| `Error` | 3 | Agent hit an unrecoverable state |
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
1. **Angular Signals** — Reactive state management. No NgRx — kept intentionally simple.
|
||||
2. **Adaptive layout** — Sidebar nav (NavRail) on desktop/kiosk (≥768px); bottom nav on mobile.
|
||||
3. **Tactical Dark Mode** — Theme via CSS custom properties in `styles.scss`.
|
||||
4. **SignalR fleet group** — Clients call `JoinFleet()` to subscribe to all agent updates broadcast by the hub.
|
||||
5. **Backend push model** — Hub uses `IHubContext<AgentStatusHub, IAgentStatusClient>` extension methods to push to clients; no polling.
|
||||
6. **Gateway bridge** — `GatewayEventBridgeService` connects the OpenClaw gateway WebSocket to the SignalR hub.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8 SDK
|
||||
- Node.js 20+
|
||||
- Docker
|
||||
- PostgreSQL
|
||||
- Running OpenClaw gateway (for live agent events)
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Restore and build
|
||||
dotnet restore
|
||||
dotnet build
|
||||
|
||||
# Apply migrations
|
||||
dotnet ef database update --project ControlCenter
|
||||
|
||||
# Run API
|
||||
dotnet run --project ControlCenter
|
||||
```
|
||||
|
||||
API runs at `http://localhost:5000` · Swagger at `http://localhost:5000/swagger`
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
ng serve
|
||||
```
|
||||
|
||||
Frontend runs at `http://localhost:4200`
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`backend/ControlCenter/appsettings.json` — override in `appsettings.Development.json` or environment variables:
|
||||
|
||||
| Key | Default | Description |
|
||||
|---|---|---|
|
||||
| `ConnectionStrings:DefaultConnection` | *(set in dev config)* | PostgreSQL connection string |
|
||||
| `Gateway:WebSocketUrl` | `ws://localhost:3271/ws` | OpenClaw gateway WebSocket URL |
|
||||
| `Gateway:AuthToken` | `""` | Gateway auth token |
|
||||
| `Cors:AllowedOrigins` | `localhost:4200`, `localhost:5000` | Frontend origins for CORS |
|
||||
|
||||
---
|
||||
|
||||
## Real-Time Events
|
||||
|
||||
SignalR hub endpoint: `/hubs/agent-status`
|
||||
|
||||
### Hub Methods (server → client)
|
||||
|
||||
| Method | Payload | Description |
|
||||
|---|---|---|
|
||||
| `ReceiveStatusUpdate` | `AgentStatusUpdateDto` | Agent status changed |
|
||||
| `ReceiveAgentList` | `Agent[]` | Full fleet snapshot on join |
|
||||
|
||||
### Client → Server
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `JoinFleet()` | Subscribe to all agent updates |
|
||||
| `LeaveFleet()` | Unsubscribe |
|
||||
| `SendStatusUpdate(dto)` | Push a status update (internal use) |
|
||||
|
||||
---
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | Page | Description |
|
||||
|---|---|---|
|
||||
| `/hub` | Command Hub | Live agent grid — status, task, progress |
|
||||
| `/logs` | Logs | Agent session log viewer |
|
||||
| `/projects` | Projects | Linear project tracking view |
|
||||
| `/sessions` | Sessions | OpenClaw session browser |
|
||||
| `/settings` | Settings | App configuration |
|
||||
|
||||
---
|
||||
|
||||
## Agent Fleet
|
||||
|
||||
| Agent | Role |
|
||||
|---|---|
|
||||
| Otto | Orchestrator — owns the dev lifecycle |
|
||||
| Rex | Frontend (Angular) |
|
||||
| Dex | Backend (ASP.NET Core) |
|
||||
| Hex | Database (schema, migrations) |
|
||||
| Pip | Raspberry Pi / Python |
|
||||
| Nano | Arduino / ESP32 / ESPHome |
|
||||
| Sketch | UX/UI design |
|
||||
| Flip | Mobile development |
|
||||
| Bob | Content & marketing copy |
|
||||
| Stuart | Concept visualization (images) |
|
||||
| Norbert | 3D design (OpenSCAD) |
|
||||
|
||||
---
|
||||
|
||||
## Branch & PR Rules
|
||||
|
||||
- All feature branches target `dev` — **never `main`**
|
||||
- Branch naming: `agent/<agent>/CUB-N-short-description`
|
||||
- PR titles: `CUB-N: short description`
|
||||
- PRs require Otto review before Joshua merges
|
||||
|
||||
---
|
||||
|
||||
## API Overview
|
||||
|
||||
| Route prefix | Resource |
|
||||
|---|---|
|
||||
| `/api/agents` | Agent registry and status |
|
||||
| `/api/command` | Issue commands to agents |
|
||||
| `/api/logs` | Session log retrieval |
|
||||
|
||||
Full schema at `/swagger` when running in dev.
|
||||
|
||||
---
|
||||
|
||||
*Built by CubeCraft Creations · Orchestrated by Otto*
|
||||
|
||||
21
backend/.gitignore
vendored
21
backend/.gitignore
vendored
@@ -1,21 +0,0 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
*.cache
|
||||
*.dll
|
||||
*.pdb
|
||||
|
||||
## IDE
|
||||
.vs/
|
||||
.idea/
|
||||
*.swp
|
||||
*~
|
||||
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
## Environment
|
||||
.env
|
||||
@@ -1,88 +0,0 @@
|
||||
using ControlCenter.Api.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ControlCenter.Api.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core entity type configuration for the agents table.
|
||||
/// Enforces snake_case naming, required fields, and index design.
|
||||
/// </summary>
|
||||
public class AgentConfiguration : IEntityTypeConfiguration<Agent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Agent> builder)
|
||||
{
|
||||
// Table name — snake_case
|
||||
builder.ToTable("agents");
|
||||
|
||||
// Primary key
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
// Status — stored as PostgreSQL enum via Npgsql
|
||||
builder.Property(a => a.Status)
|
||||
.HasColumnName("status")
|
||||
.HasColumnType("agent_status")
|
||||
.IsRequired();
|
||||
|
||||
// Task — nullable text
|
||||
builder.Property(a => a.Task)
|
||||
.HasColumnName("task")
|
||||
.HasColumnType("text");
|
||||
|
||||
// Progress — nullable integer (0–100)
|
||||
builder.Property(a => a.Progress)
|
||||
.HasColumnName("progress");
|
||||
|
||||
// Session key — required, not null
|
||||
builder.Property(a => a.SessionKey)
|
||||
.HasColumnName("session_key")
|
||||
.HasColumnType("text")
|
||||
.IsRequired();
|
||||
|
||||
// Channel — required, not null
|
||||
builder.Property(a => a.Channel)
|
||||
.HasColumnName("channel")
|
||||
.HasColumnType("text")
|
||||
.IsRequired();
|
||||
|
||||
// Last activity — required, defaults to now()
|
||||
builder.Property(a => a.LastActivity)
|
||||
.HasColumnName("last_activity")
|
||||
.HasColumnType("timestamptz")
|
||||
.IsRequired();
|
||||
|
||||
// Created at — auto-set on insert
|
||||
builder.Property(a => a.CreatedAt)
|
||||
.HasColumnName("created_at")
|
||||
.HasColumnType("timestamptz")
|
||||
.IsRequired()
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
// Updated at — auto-set on insert and update
|
||||
builder.Property(a => a.UpdatedAt)
|
||||
.HasColumnName("updated_at")
|
||||
.HasColumnType("timestamptz")
|
||||
.IsRequired()
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
// Indexes
|
||||
// Sessions are looked up by session_key frequently
|
||||
builder.HasIndex(a => a.SessionKey)
|
||||
.HasDatabaseName("ix_agents_session_key")
|
||||
.IsUnique();
|
||||
|
||||
// Agents are filtered by channel for channel-specific queries
|
||||
builder.HasIndex(a => a.Channel)
|
||||
.HasDatabaseName("ix_agents_channel");
|
||||
|
||||
// Agents are filtered by status for fleet health monitoring
|
||||
builder.HasIndex(a => a.Status)
|
||||
.HasDatabaseName("ix_agents_status");
|
||||
|
||||
// Check constraint: progress must be 0–100 if present
|
||||
builder.ToTable(t => t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)"));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
@ControlCenter.Api_HostAddress = http://localhost:5178
|
||||
|
||||
GET {{ControlCenter.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
18
backend/ControlCenter/.gitignore
vendored
18
backend/ControlCenter/.gitignore
vendored
@@ -1,18 +0,0 @@
|
||||
## .NET
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
*.cache
|
||||
*.dll
|
||||
*.pdb
|
||||
*.xml
|
||||
|
||||
## IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
## OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,17 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>CS1591</NoWarn>
|
||||
<RootNamespace>ControlCenter</RootNamespace>
|
||||
<AssemblyName>ControlCenter</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,71 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ControlCenter.Services;
|
||||
|
||||
namespace ControlCenter.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API for querying agent fleet status.
|
||||
/// Provides the initial data load for the Command Hub,
|
||||
/// while real-time updates flow through the AgentStatus SignalR hub.
|
||||
///
|
||||
/// <para>API contract for Rex (Frontend):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GET /api/agents</c> — Returns all known agents with current status</item>
|
||||
/// <item><c>GET /api/agents/{agentId}</c> — Returns a specific agent's status</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AgentsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<AgentsController> _logger;
|
||||
private readonly GatewayEventBridgeService _bridgeService;
|
||||
|
||||
public AgentsController(
|
||||
ILogger<AgentsController> logger,
|
||||
GatewayEventBridgeService bridgeService)
|
||||
{
|
||||
_logger = logger;
|
||||
_bridgeService = bridgeService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current fleet status — all known agents with their latest state.
|
||||
/// This is the initial load endpoint; subsequent updates arrive via SignalR.
|
||||
/// </summary>
|
||||
/// <returns>An array of agent card data for the entire fleet.</returns>
|
||||
/// <response code="200">Returns the fleet snapshot.</response>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(AgentCardData[]), StatusCodes.Status200OK)]
|
||||
public IActionResult GetAgents()
|
||||
{
|
||||
var snapshot = _bridgeService.GetFleetSnapshot();
|
||||
_logger.LogDebug("Fleet snapshot requested: {Count} agents", snapshot.Length);
|
||||
return Ok(snapshot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current status of a specific agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <returns>The agent's current card data.</returns>
|
||||
/// <response code="200">Returns the agent's status.</response>
|
||||
/// <response code="404">Agent not found in the fleet state.</response>
|
||||
[HttpGet("{agentId}")]
|
||||
[ProducesResponseType(typeof(AgentCardData), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetAgent(string agentId)
|
||||
{
|
||||
var snapshot = _bridgeService.GetFleetSnapshot();
|
||||
var agent = snapshot.FirstOrDefault(a =>
|
||||
a.Id.Equals(agentId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (agent is null)
|
||||
{
|
||||
_logger.LogWarning("Agent not found: {AgentId}", agentId);
|
||||
return NotFound(new { error = $"Agent '{agentId}' not found" });
|
||||
}
|
||||
|
||||
return Ok(agent);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ControlCenter.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API for sending control commands to agents.
|
||||
/// Provides the Command Hub's action endpoints for agent lifecycle control.
|
||||
///
|
||||
/// <para>API contract for Rex (Frontend):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>POST /api/command/stop/{agentId}</c> — Stop/abort an agent's active session</item>
|
||||
/// <item><c>POST /api/command/restart/{agentId}</c> — Restart an agent</item>
|
||||
/// <item><c>POST /api/command/steer/{agentId}</c> — Inject a message into an agent's session</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Commands are forwarded to the OpenClaw Gateway via the
|
||||
/// WebSocket bridge service. The Gateway handles the actual execution.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class CommandController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<CommandController> _logger;
|
||||
|
||||
public CommandController(ILogger<CommandController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops (aborts) an agent's active session.
|
||||
/// Sends an abort command to the OpenClaw Gateway.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier to stop.</param>
|
||||
/// <returns>Confirmation of the stop command.</returns>
|
||||
/// <response code="200">Stop command sent successfully.</response>
|
||||
/// <response code="404">No active session found for the agent.</response>
|
||||
[HttpPost("stop/{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult StopAgent(string agentId)
|
||||
{
|
||||
_logger.LogInformation("Stop command received for agent {AgentId}", agentId);
|
||||
|
||||
// TODO: Forward to Gateway via bridge service
|
||||
// await _bridgeService.SendRpcAsync("sessions.abort", new { sessionKey = ... });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
command = "stop",
|
||||
status = "sent",
|
||||
timestamp = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restarts an agent by aborting the current session and allowing
|
||||
/// a new one to start on the next incoming message.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier to restart.</param>
|
||||
/// <returns>Confirmation of the restart command.</returns>
|
||||
/// <response code="200">Restart command sent successfully.</response>
|
||||
[HttpPost("restart/{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult RestartAgent(string agentId)
|
||||
{
|
||||
_logger.LogInformation("Restart command received for agent {AgentId}", agentId);
|
||||
|
||||
// TODO: Forward to Gateway — abort current session + signal restart
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
command = "restart",
|
||||
status = "sent",
|
||||
timestamp = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steers (injects a message into) an agent's active session.
|
||||
/// Used by operators to redirect an agent's task mid-execution.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier to steer.</param>
|
||||
/// <param name="request">The steering message to inject.</param>
|
||||
/// <returns>Confirmation of the steer command.</returns>
|
||||
/// <response code="200">Steer command sent successfully.</response>
|
||||
/// <response code="400">Missing or empty message.</response>
|
||||
[HttpPost("steer/{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public IActionResult SteerAgent(string agentId, [FromBody] SteerRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Message))
|
||||
{
|
||||
return BadRequest(new { error = "Message is required" });
|
||||
}
|
||||
|
||||
_logger.LogInformation("Steer command received for agent {AgentId}: {Message}",
|
||||
agentId, request.Message.Length > 100
|
||||
? request.Message[..100] + "..." : request.Message);
|
||||
|
||||
// TODO: Forward to Gateway via bridge service
|
||||
// await _bridgeService.SendRpcAsync("sessions.steer", new { sessionKey = ..., message = request.Message });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
command = "steer",
|
||||
message = request.Message,
|
||||
status = "sent",
|
||||
timestamp = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request body for the steer command.
|
||||
/// </summary>
|
||||
/// <param name="Message">The message to inject into the agent's session.</param>
|
||||
public record SteerRequest(string Message);
|
||||
@@ -1,87 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ControlCenter.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API for querying agent session logs.
|
||||
/// Provides historical message and tool call logs for a specific agent.
|
||||
///
|
||||
/// <para>API contract for Rex (Frontend):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GET /api/logs/{agentId}</c> — Returns recent logs for an agent</item>
|
||||
/// <item><c>GET /api/logs/{agentId}/tools</c> — Returns recent tool calls for an agent</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Log data is sourced from the OpenClaw Gateway's transcript files.
|
||||
/// The Gateway's <c>logs.tail</c> RPC provides the raw data, and this
|
||||
/// controller formats it for the frontend.</para>
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class LogsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<LogsController> _logger;
|
||||
|
||||
public LogsController(ILogger<LogsController> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent session logs for a specific agent.
|
||||
/// Returns the last N messages from the agent's active session transcript.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <param name="limit">Maximum number of log entries to return (default: 50, max: 200).</param>
|
||||
/// <returns>An array of log entries for the agent.</returns>
|
||||
/// <response code="200">Returns the agent's recent logs.</response>
|
||||
/// <response code="404">No active session found for the agent.</response>
|
||||
[HttpGet("{agentId}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetLogs(string agentId, [FromQuery] int limit = 50)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 200);
|
||||
|
||||
_logger.LogDebug("Logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
|
||||
|
||||
// TODO: Implement log retrieval by calling the Gateway's logs.tail RPC
|
||||
// or reading transcript files. For now, return an empty array as the
|
||||
// bridge service will provide this data when fully integrated.
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
logs = Array.Empty<object>(),
|
||||
count = 0,
|
||||
hasMore = false
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent tool call logs for a specific agent.
|
||||
/// Returns the last N tool invocations from the agent's session.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier.</param>
|
||||
/// <param name="limit">Maximum number of tool entries to return (default: 20, max: 100).</param>
|
||||
/// <returns>An array of tool call entries for the agent.</returns>
|
||||
/// <response code="200">Returns the agent's recent tool calls.</response>
|
||||
/// <response code="404">No active session found for the agent.</response>
|
||||
[HttpGet("{agentId}/tools")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public IActionResult GetToolLogs(string agentId, [FromQuery] int limit = 20)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
_logger.LogDebug("Tool logs requested for agent {AgentId}, limit {Limit}", agentId, limit);
|
||||
|
||||
// TODO: Implement tool log retrieval. Return empty for now.
|
||||
return Ok(new
|
||||
{
|
||||
agentId,
|
||||
tools = Array.Empty<object>(),
|
||||
count = 0,
|
||||
hasMore = false
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ControlCenter.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for real-time agent status updates in the Command Hub.
|
||||
///
|
||||
/// <para>Usage flow:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Client connects to <c>/hubs/agent-status</c></item>
|
||||
/// <item>Client calls <see cref="JoinFleet"/> to subscribe to all agent updates</item>
|
||||
/// <item>Client calls <see cref="JoinAgentGroup"/> to subscribe to a specific agent</item>
|
||||
/// <item>Server pushes <see cref="IAgentStatusClient.AgentStatusChanged"/>
|
||||
/// and <see cref="IAgentStatusClient.AgentTaskProgress"/> events</item>
|
||||
/// <item>Client calls <see cref="GetFleetSnapshot"/> for initial state on connect</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Group naming:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Fleet group: <c>fleet</c> — receives all agent updates</item>
|
||||
/// <item>Agent group: <c>agent:{agentId}</c> — receives updates for one agent</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>Typed client: <see cref="IAgentStatusClient"/> — all server-to-client
|
||||
/// calls go through this interface for compile-time safety.</para>
|
||||
///
|
||||
/// <para>Architecture note: This hub bridges OpenClaw Gateway WebSocket events
|
||||
/// to SignalR clients. A background service (<see cref="Services.GatewayEventBridgeService"/>)
|
||||
/// subscribes to Gateway events and pushes them through this hub's extension methods.</para>
|
||||
/// </summary>
|
||||
public class AgentStatusHub : Hub<IAgentStatusClient>
|
||||
{
|
||||
private readonly ILogger<AgentStatusHub> _logger;
|
||||
|
||||
public AgentStatusHub(ILogger<AgentStatusHub> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the calling connection to the fleet group.
|
||||
/// Once joined, the client will receive all agent status changes
|
||||
/// and task progress updates across the entire fleet.
|
||||
/// </summary>
|
||||
public async Task JoinFleet()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the calling connection from the fleet group.
|
||||
/// </summary>
|
||||
public async Task LeaveFleet()
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the calling connection to a specific agent's group.
|
||||
/// Once joined, the client will receive updates only for that agent.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <exception cref="HubException">Thrown if agentId is null or empty.</exception>
|
||||
public async Task JoinAgentGroup(string agentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
throw new HubException("agentId is required");
|
||||
|
||||
var groupName = AgentGroupName(agentId);
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} joined agent group {GroupName}",
|
||||
Context.ConnectionId, groupName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the calling connection from a specific agent's group.
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier.</param>
|
||||
public async Task LeaveAgentGroup(string agentId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(agentId)) return;
|
||||
|
||||
var groupName = AgentGroupName(agentId);
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a snapshot of the current fleet state.
|
||||
/// Called by clients on initial connection to get the full picture
|
||||
/// before incremental updates begin arriving.
|
||||
/// </summary>
|
||||
/// <returns>An array of <see cref="AgentCardData"/> representing all known agents.</returns>
|
||||
public Task<AgentCardData[]> GetFleetSnapshot()
|
||||
{
|
||||
// The fleet state is managed by the GatewayEventBridgeService.
|
||||
// For now, return an empty array — the bridge service will push
|
||||
// updates as they arrive from the Gateway.
|
||||
_logger.LogDebug("Fleet snapshot requested by {ConnectionId}", Context.ConnectionId);
|
||||
return Task.FromResult(Array.Empty<AgentCardData>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
|
||||
/// SignalR automatically removes disconnected connections from all groups.
|
||||
/// </summary>
|
||||
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SignalR group name for the entire fleet (all agents).
|
||||
/// </summary>
|
||||
internal const string FleetGroupName = "fleet";
|
||||
|
||||
/// <summary>
|
||||
/// Returns the SignalR group name for a specific agent.
|
||||
/// Format: <c>agent:{agentId}</c> (lowercase for consistency).
|
||||
/// </summary>
|
||||
/// <param name="agentId">The agent identifier.</param>
|
||||
internal static string AgentGroupName(string agentId) =>
|
||||
$"agent:{agentId.ToLowerInvariant()}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for pushing real-time agent updates through
|
||||
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
|
||||
///
|
||||
/// <para>These methods are intended to be called from background services
|
||||
/// (e.g., <see cref="Services.GatewayEventBridgeService"/>) or other
|
||||
/// server-side code that detects an agent state change.</para>
|
||||
/// </summary>
|
||||
public static class AgentStatusHubExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status change to all clients subscribed to
|
||||
/// the fleet group and the specific agent's group.
|
||||
///
|
||||
/// <para>Call this from any background service when an agent's
|
||||
/// operational status changes (e.g., the Gateway reports a
|
||||
/// session transition from "running" to "done").</para>
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="update">The agent status update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||
public static async Task PushAgentStatusAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
AgentStatusUpdate update)
|
||||
{
|
||||
// Broadcast to the fleet group (all subscribers)
|
||||
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||
.AgentStatusChanged(update);
|
||||
|
||||
// Also push to the specific agent's group
|
||||
var agentGroup = AgentStatusHub.AgentGroupName(update.AgentId);
|
||||
await hubContext.Clients.Group(agentGroup)
|
||||
.AgentStatusChanged(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a task progress update to all clients subscribed to
|
||||
/// the fleet group and the specific agent's group.
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="progress">The task progress update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
||||
public static async Task PushTaskProgressAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
TaskProgressUpdate progress)
|
||||
{
|
||||
// Broadcast to the fleet group
|
||||
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||
.AgentTaskProgress(progress);
|
||||
|
||||
// Also push to the specific agent's group
|
||||
var agentGroup = AgentStatusHub.AgentGroupName(progress.AgentId);
|
||||
await hubContext.Clients.Group(agentGroup)
|
||||
.AgentTaskProgress(progress);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
namespace ControlCenter.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed client interface for the AgentStatus SignalR hub.
|
||||
/// Defines the methods the server can invoke on connected clients
|
||||
/// to push real-time agent status and task progress updates.
|
||||
///
|
||||
/// <para>All server-to-client calls go through this interface for
|
||||
/// compile-time safety — matching the pattern used by the
|
||||
/// Extrudex PrinterHub.</para>
|
||||
/// </summary>
|
||||
public interface IAgentStatusClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status change to all subscribed clients.
|
||||
/// Fired whenever an agent's operational status changes
|
||||
/// (e.g., idle → active, active → thinking, active → error).
|
||||
/// </summary>
|
||||
/// <param name="update">The full status update payload.</param>
|
||||
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||
Task AgentStatusChanged(AgentStatusUpdate update);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a task progress update to all subscribed clients.
|
||||
/// Fired when an agent reports progress on its current task.
|
||||
/// </summary>
|
||||
/// <param name="progress">The task progress update payload.</param>
|
||||
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||
Task AgentTaskProgress(TaskProgressUpdate progress);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
namespace ControlCenter;
|
||||
|
||||
/// <summary>
|
||||
/// Agent operational status derived from OpenClaw Gateway session activity.
|
||||
/// Maps to the frontend AgentStatus type: 'active' | 'idle' | 'thinking' | 'error'.
|
||||
/// </summary>
|
||||
public enum AgentStatus
|
||||
{
|
||||
/// <summary>Agent is currently processing a turn.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Agent completed its last turn; no active work.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>LLM call in flight; tokens streaming.</summary>
|
||||
Thinking,
|
||||
|
||||
/// <summary>Agent encountered an unhandled error.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extended lifecycle status including offline — not all agents have active sessions.
|
||||
/// Used internally; clients only see <see cref="AgentStatus"/> (offline maps to idle).
|
||||
/// </summary>
|
||||
public enum AgentLifecycleStatus
|
||||
{
|
||||
Active,
|
||||
Idle,
|
||||
Thinking,
|
||||
Error,
|
||||
Offline
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushed to SignalR clients when an agent's status changes.
|
||||
/// Matches the TypeScript <c>AgentStatusUpdate</c> interface from the design spec.
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier, e.g. "otto", "dex".</param>
|
||||
/// <param name="DisplayName">Human-readable name, e.g. "Otto".</param>
|
||||
/// <param name="Role">Role description, e.g. "Orchestrator Agent".</param>
|
||||
/// <param name="Status">Current operational status.</param>
|
||||
/// <param name="CurrentTask">Description of the current task, if any.</param>
|
||||
/// <param name="SessionKey">Full session key, e.g. "agent:otto:telegram:direct:8787451565".</param>
|
||||
/// <param name="Channel">Channel the agent is operating on, e.g. "telegram".</param>
|
||||
/// <param name="LastActivity">ISO 8601 timestamp of last activity.</param>
|
||||
/// <param name="ErrorMessage">Error message when status is 'error'.</param>
|
||||
public record AgentStatusUpdate(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
string Role,
|
||||
string Status,
|
||||
string? CurrentTask,
|
||||
string SessionKey,
|
||||
string Channel,
|
||||
string LastActivity,
|
||||
string? ErrorMessage = null
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Pushed to SignalR clients when an agent's task progress updates.
|
||||
/// Matches the TypeScript <c>TaskProgressUpdate</c> interface from the design spec.
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier.</param>
|
||||
/// <param name="TaskDescription">Description of the current task.</param>
|
||||
/// <param name="Progress">Task progress percentage (0–100), if trackable.</param>
|
||||
/// <param name="Elapsed">Elapsed time string, e.g. "04m 12s".</param>
|
||||
public record TaskProgressUpdate(
|
||||
string AgentId,
|
||||
string TaskDescription,
|
||||
int? Progress,
|
||||
string? Elapsed
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of an agent's full card data, sent on initial connection
|
||||
/// or when the fleet state is requested.
|
||||
/// Matches the TypeScript <c>AgentCardData</c> interface from the design spec.
|
||||
/// </summary>
|
||||
public record AgentCardData(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string Role,
|
||||
string Status,
|
||||
string? CurrentTask,
|
||||
int? TaskProgress,
|
||||
string? TaskElapsed,
|
||||
string SessionKey,
|
||||
string Channel,
|
||||
string LastActivity,
|
||||
string? ErrorMessage
|
||||
);
|
||||
@@ -1,72 +0,0 @@
|
||||
using System.Reflection;
|
||||
using ControlCenter.Hubs;
|
||||
using ControlCenter.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ── API Services ───────────────────────────────────────────
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "Control Center API",
|
||||
Version = "v1",
|
||||
Description = "OpenClaw Control Center — Command Hub backend with SignalR real-time updates"
|
||||
});
|
||||
|
||||
// Include XML doc comments in Swagger output
|
||||
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
|
||||
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
|
||||
if (File.Exists(xmlPath))
|
||||
{
|
||||
c.IncludeXmlComments(xmlPath);
|
||||
}
|
||||
});
|
||||
|
||||
// ── CORS (kiosk + remote browser) ─────────────────────────
|
||||
// The Control Center frontend runs on a different origin than the backend.
|
||||
// SignalR requires credentials for WebSocket transport, so we use
|
||||
// specific origins rather than AllowAnyOrigin.
|
||||
var corsOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins")
|
||||
.Get<string[]>() ?? new[] { "http://localhost:4200", "http://localhost:5000" };
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins(corsOrigins)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials(); // Required for SignalR WebSocket
|
||||
});
|
||||
});
|
||||
|
||||
// ── SignalR (real-time agent status updates) ───────────────
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// ── Gateway Bridge Service ────────────────────────────────
|
||||
// Background service that connects to the OpenClaw Gateway WebSocket
|
||||
// and bridges events to the AgentStatus SignalR hub.
|
||||
builder.Services.AddSingleton<GatewayEventBridgeService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GatewayEventBridgeService>());
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ── Middleware ──────────────────────────────────────────────
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
// ── Hub Endpoints ───────────────────────────────────────────
|
||||
// Agent status hub at /hubs/agent-status (matches the design spec)
|
||||
app.MapHub<AgentStatusHub>("/hubs/agent-status");
|
||||
|
||||
app.Run();
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5053",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,523 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ControlCenter.Hubs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ControlCenter.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that connects to the OpenClaw Gateway WebSocket
|
||||
/// and bridges Gateway events to the <see cref="Hubs.AgentStatusHub"/>.
|
||||
///
|
||||
/// <para>Architecture:</para>
|
||||
/// <list type="number">
|
||||
/// <item>Connects to the Gateway WS endpoint (configurable via appsettings)</item>
|
||||
/// <item>Handles the v3 protocol handshake (challenge → connect → hello-ok)</item>
|
||||
/// <item>Subscribes to <c>sessions.changed</c> and related events</item>
|
||||
/// <item>Translates session state changes into <see cref="AgentStatusUpdate"/>
|
||||
/// and <see cref="TaskProgressUpdate"/> objects</item>
|
||||
/// <item>Pushes updates through the <see cref="Hubs.AgentStatusHub"/> SignalR hub</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>This is the server-side bridge that allows Angular clients to
|
||||
/// receive real-time updates via SignalR instead of connecting directly
|
||||
/// to the Gateway WebSocket.</para>
|
||||
/// </summary>
|
||||
public class GatewayEventBridgeService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<GatewayEventBridgeService> _logger;
|
||||
private readonly IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> _hubContext;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory fleet state — maps agent IDs to their latest card data.
|
||||
/// Updated on every <c>sessions.changed</c> event from the Gateway.
|
||||
/// </summary>
|
||||
private readonly ConcurrentDictionary<string, AgentCardData> _fleetState = new();
|
||||
|
||||
/// <summary>
|
||||
/// Known agent roles for display in the Command Hub.
|
||||
/// Maps agent IDs to their functional descriptions.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> AgentRoles = new()
|
||||
{
|
||||
["main"] = "Primary Assistant",
|
||||
["otto"] = "Orchestrator Agent",
|
||||
["dave"] = "Network Admin Agent",
|
||||
["bob"] = "Content Writer Agent",
|
||||
["stuart"] = "Image & Creative Agent",
|
||||
["phil"] = "Home Automation Agent",
|
||||
["carl"] = "Security Agent",
|
||||
["larry"] = "Business Agent",
|
||||
["mel"] = "E-Commerce Agent",
|
||||
["norbert"] = "Product Agent",
|
||||
["jerry"] = "Market Research Agent",
|
||||
["rex"] = "Frontend Dev Agent",
|
||||
["dex"] = "Backend Dev Agent",
|
||||
["hex"] = "Database Agent",
|
||||
["pip"] = "Raspberry Pi Agent",
|
||||
["nano"] = "ESP32/Firmware Agent",
|
||||
["axiom"] = "Utility Agent",
|
||||
["bonnie"] = "Music Agent",
|
||||
["sketch"] = "UI/UX Design Agent",
|
||||
["flip"] = "Mobile Dev Agent",
|
||||
["buzz"] = "SEO Agent",
|
||||
["aries"] = "Companion Agent"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps OpenClaw session status to <see cref="AgentStatus"/>.
|
||||
/// </summary>
|
||||
private static string MapSessionStatus(string? sessionStatus) => sessionStatus switch
|
||||
{
|
||||
"running" => "active",
|
||||
"streaming" => "thinking",
|
||||
"error" or "aborted" => "error",
|
||||
"done" => "idle",
|
||||
_ => "idle"
|
||||
};
|
||||
|
||||
public GatewayEventBridgeService(
|
||||
ILogger<GatewayEventBridgeService> logger,
|
||||
IHubContext<Hubs.AgentStatusHub, Hubs.IAgentStatusClient> hubContext,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_hubContext = hubContext;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current fleet state snapshot.
|
||||
/// Used by the hub's <c>GetFleetSnapshot</c> method and by the
|
||||
/// <c>AgentsController</c> REST endpoint.
|
||||
/// </summary>
|
||||
public AgentCardData[] GetFleetSnapshot() =>
|
||||
_fleetState.Values.ToArray();
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Gateway Event Bridge service starting");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAndListenAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Gateway Event Bridge service stopping");
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Gateway connection lost, reconnecting in 5 seconds...");
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the OpenClaw Gateway WebSocket and processes events
|
||||
/// until the connection is lost or cancellation is requested.
|
||||
/// </summary>
|
||||
private async Task ConnectAndListenAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var gatewayUrl = _configuration["Gateway:WebSocketUrl"]
|
||||
?? "ws://localhost:3271/ws";
|
||||
var authToken = _configuration["Gateway:AuthToken"] ?? string.Empty;
|
||||
|
||||
_logger.LogInformation("Connecting to Gateway at {Url}", gatewayUrl);
|
||||
|
||||
using var ws = new ClientWebSocket();
|
||||
|
||||
// Set auth header if available
|
||||
if (!string.IsNullOrEmpty(authToken))
|
||||
{
|
||||
ws.Options.SetRequestHeader("Authorization", $"Bearer {authToken}");
|
||||
}
|
||||
|
||||
await ws.ConnectAsync(new Uri(gatewayUrl), stoppingToken);
|
||||
_logger.LogInformation("Connected to Gateway WebSocket");
|
||||
|
||||
// Start receiving messages
|
||||
await ReceiveMessagesAsync(ws, stoppingToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Receives and processes WebSocket messages from the Gateway.
|
||||
/// Handles the v3 protocol handshake and dispatches events.
|
||||
/// </summary>
|
||||
private async Task ReceiveMessagesAsync(ClientWebSocket ws, CancellationToken stoppingToken)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
var messageBuilder = new StringBuilder();
|
||||
|
||||
while (ws.State == WebSocketState.Open && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
WebSocketReceiveResult result;
|
||||
try
|
||||
{
|
||||
result = await ws.ReceiveAsync(buffer, stoppingToken);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "WebSocket receive error");
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
_logger.LogInformation("Gateway WebSocket closed by server");
|
||||
break;
|
||||
}
|
||||
|
||||
messageBuilder.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
var message = messageBuilder.ToString();
|
||||
messageBuilder.Clear();
|
||||
await ProcessMessageAsync(ws, message, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single WebSocket message from the Gateway.
|
||||
/// Routes based on the message type: event, response, or challenge.
|
||||
/// </summary>
|
||||
private async Task ProcessMessageAsync(
|
||||
ClientWebSocket ws, string message, CancellationToken stoppingToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var type = root.GetProperty("type").GetString();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "event":
|
||||
await HandleGatewayEventAsync(root);
|
||||
break;
|
||||
|
||||
case "res":
|
||||
HandleGatewayResponse(root);
|
||||
break;
|
||||
}
|
||||
|
||||
// Special handling for connect.challenge events
|
||||
if (root.TryGetProperty("event", out var eventName) &&
|
||||
eventName.GetString() == "connect.challenge")
|
||||
{
|
||||
await HandleConnectChallengeAsync(ws, root, stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to parse Gateway message: {Message}",
|
||||
message.Length > 200 ? message[..200] + "..." : message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the Gateway connect.challenge event by sending
|
||||
/// a connect request with authentication credentials.
|
||||
/// </summary>
|
||||
private async Task HandleConnectChallengeAsync(
|
||||
ClientWebSocket ws, JsonElement root, CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Received connect.challenge from Gateway");
|
||||
|
||||
var connectRequest = new
|
||||
{
|
||||
type = "req",
|
||||
id = $"bridge-{Guid.NewGuid():N}",
|
||||
method = "connect",
|
||||
@params = new
|
||||
{
|
||||
minProtocol = 3,
|
||||
maxProtocol = 3,
|
||||
client = new
|
||||
{
|
||||
id = "control-center-backend",
|
||||
version = "1.0.0",
|
||||
platform = "server",
|
||||
mode = "operator"
|
||||
},
|
||||
role = "operator",
|
||||
scopes = new[] { "operator.read", "operator.write" },
|
||||
auth = new
|
||||
{
|
||||
token = _configuration["Gateway:AuthToken"] ?? string.Empty
|
||||
},
|
||||
locale = "en-US",
|
||||
userAgent = "control-center-backend/1.0.0"
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(connectRequest);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
await ws.SendAsync(bytes, WebSocketMessageType.Text, true, stoppingToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a Gateway event message by dispatching to the
|
||||
/// appropriate handler based on event name.
|
||||
/// </summary>
|
||||
private async Task HandleGatewayEventAsync(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("event", out var eventProp)) return;
|
||||
var eventName = eventProp.GetString();
|
||||
|
||||
_logger.LogDebug("Gateway event: {Event}", eventName);
|
||||
|
||||
switch (eventName)
|
||||
{
|
||||
case "sessions.changed":
|
||||
await HandleSessionsChangedAsync(root);
|
||||
break;
|
||||
|
||||
case "session.message":
|
||||
HandleSessionMessage(root);
|
||||
break;
|
||||
|
||||
case "session.tool":
|
||||
HandleSessionTool(root);
|
||||
break;
|
||||
|
||||
case "health":
|
||||
HandleHealthEvent(root);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a sessions.changed event from the Gateway.
|
||||
/// Updates the fleet state and pushes status changes through SignalR.
|
||||
/// </summary>
|
||||
private async Task HandleSessionsChangedAsync(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
|
||||
// The payload may contain a snapshot of all sessions
|
||||
if (payload.TryGetProperty("snapshot", out var snapshot) &&
|
||||
snapshot.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var session in snapshot.EnumerateArray())
|
||||
{
|
||||
var cardData = SessionToCardData(session);
|
||||
if (cardData is null) continue;
|
||||
|
||||
_fleetState[cardData.Id] = cardData;
|
||||
|
||||
var update = new AgentStatusUpdate(
|
||||
AgentId: cardData.Id,
|
||||
DisplayName: cardData.DisplayName,
|
||||
Role: cardData.Role,
|
||||
Status: cardData.Status,
|
||||
CurrentTask: cardData.CurrentTask,
|
||||
SessionKey: cardData.SessionKey,
|
||||
Channel: cardData.Channel,
|
||||
LastActivity: cardData.LastActivity,
|
||||
ErrorMessage: cardData.ErrorMessage
|
||||
);
|
||||
|
||||
await _hubContext.PushAgentStatusAsync(update);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle individual updates/added/removed
|
||||
if (payload.TryGetProperty("updated", out var updated) &&
|
||||
updated.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var sessionKey in updated.EnumerateArray())
|
||||
{
|
||||
_logger.LogDebug("Session updated: {Key}", sessionKey.GetString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a session.message event. Updates the agent's last activity
|
||||
/// and pushes a status update if the status changed.
|
||||
/// </summary>
|
||||
private void HandleSessionMessage(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
|
||||
|
||||
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
|
||||
var agentId = ExtractAgentId(sessionKey);
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return;
|
||||
|
||||
// Update last activity timestamp
|
||||
if (_fleetState.TryGetValue(agentId, out var existing))
|
||||
{
|
||||
_fleetState[agentId] = existing with
|
||||
{
|
||||
LastActivity = DateTime.UtcNow.ToString("o"),
|
||||
Status = "active"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a session.tool event. Extracts tool progress information
|
||||
/// and pushes a <see cref="TaskProgressUpdate"/> through SignalR.
|
||||
/// </summary>
|
||||
private void HandleSessionTool(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
if (!payload.TryGetProperty("sessionKey", out var sessionKeyProp)) return;
|
||||
|
||||
var sessionKey = sessionKeyProp.GetString() ?? string.Empty;
|
||||
var agentId = ExtractAgentId(sessionKey);
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return;
|
||||
|
||||
var toolName = payload.TryGetProperty("toolName", out var tn) ? tn.GetString() : null;
|
||||
var toolStatus = payload.TryGetProperty("status", out var ts) ? ts.GetString() : null;
|
||||
|
||||
if (toolName is null || toolStatus is null) return;
|
||||
|
||||
var progress = toolStatus switch
|
||||
{
|
||||
"started" => 0,
|
||||
"completed" => 100,
|
||||
_ => (int?)null
|
||||
};
|
||||
|
||||
var update = new TaskProgressUpdate(
|
||||
AgentId: agentId,
|
||||
TaskDescription: $"{toolName} ({toolStatus})",
|
||||
Progress: progress,
|
||||
Elapsed: null
|
||||
);
|
||||
|
||||
// Fire and forget — don't block the event loop
|
||||
_ = _hubContext.PushTaskProgressAsync(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a health event from the Gateway.
|
||||
/// Logs the health status for diagnostics.
|
||||
/// </summary>
|
||||
private void HandleHealthEvent(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("payload", out var payload)) return;
|
||||
|
||||
var ok = payload.TryGetProperty("ok", out var okProp) && okProp.GetBoolean();
|
||||
var status = payload.TryGetProperty("status", out var s) ? s.GetString() : "unknown";
|
||||
|
||||
_logger.LogInformation("Gateway health: ok={Ok}, status={Status}", ok, status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles a Gateway response message. Currently only logs for diagnostics.
|
||||
/// </summary>
|
||||
private void HandleGatewayResponse(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("ok", out var okProp) && okProp.GetBoolean())
|
||||
{
|
||||
_logger.LogDebug("Gateway RPC response OK");
|
||||
|
||||
// Check for hello-ok after connect
|
||||
if (root.TryGetProperty("payload", out var payload) &&
|
||||
payload.TryGetProperty("type", out var typeProp) &&
|
||||
typeProp.GetString() == "hello-ok")
|
||||
{
|
||||
_logger.LogInformation("Gateway handshake complete (hello-ok received)");
|
||||
}
|
||||
}
|
||||
else if (root.TryGetProperty("error", out var error))
|
||||
{
|
||||
var errorMsg = error.TryGetProperty("message", out var msg)
|
||||
? msg.GetString() : "unknown error";
|
||||
_logger.LogWarning("Gateway RPC error: {Error}", errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw Gateway session JSON element into an
|
||||
/// <see cref="AgentCardData"/> record.
|
||||
/// </summary>
|
||||
private AgentCardData? SessionToCardData(JsonElement session)
|
||||
{
|
||||
// Extract agent ID from session key or agentId field
|
||||
string? agentId = null;
|
||||
if (session.TryGetProperty("agentId", out var aid))
|
||||
agentId = aid.GetString();
|
||||
else if (session.TryGetProperty("key", out var key))
|
||||
agentId = ExtractAgentId(key.GetString() ?? string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(agentId)) return null;
|
||||
|
||||
var sessionKey = session.TryGetProperty("key", out var sk)
|
||||
? sk.GetString() ?? string.Empty : string.Empty;
|
||||
|
||||
var status = session.TryGetProperty("status", out var s)
|
||||
? MapSessionStatus(s.GetString()) : "idle";
|
||||
|
||||
var channel = ExtractChannel(session);
|
||||
|
||||
var lastActivity = session.TryGetProperty("updatedAt", out var ua)
|
||||
? DateTimeOffset.FromUnixTimeMilliseconds(ua.GetInt64()).ToString("o")
|
||||
: DateTime.UtcNow.ToString("o");
|
||||
|
||||
var displayName = char.ToUpperInvariant(agentId![0]) + agentId[1..];
|
||||
var role = AgentRoles.GetValueOrDefault(agentId!, "Agent");
|
||||
|
||||
return new AgentCardData(
|
||||
Id: agentId!,
|
||||
DisplayName: displayName,
|
||||
Role: role,
|
||||
Status: status,
|
||||
CurrentTask: null,
|
||||
TaskProgress: null,
|
||||
TaskElapsed: null,
|
||||
SessionKey: sessionKey,
|
||||
Channel: channel,
|
||||
LastActivity: lastActivity,
|
||||
ErrorMessage: status == "error" ? "Agent encountered an error" : null
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the agent ID from a session key.
|
||||
/// Session key format: "agent:{agentId}:{channel}:..."
|
||||
/// </summary>
|
||||
private static string? ExtractAgentId(string sessionKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionKey)) return null;
|
||||
|
||||
var parts = sessionKey.Split(':');
|
||||
if (parts.Length >= 2 && parts[0] == "agent")
|
||||
return parts[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the channel from a session element.
|
||||
/// </summary>
|
||||
private static string ExtractChannel(JsonElement session)
|
||||
{
|
||||
// Try direct "channel" property
|
||||
if (session.TryGetProperty("channel", out var ch))
|
||||
return ch.GetString() ?? "unknown";
|
||||
|
||||
// Try origin.provider
|
||||
if (session.TryGetProperty("origin", out var origin) &&
|
||||
origin.TryGetProperty("provider", out var provider))
|
||||
return provider.GetString() ?? "unknown";
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"ControlCenter": "Debug"
|
||||
}
|
||||
},
|
||||
"Gateway": {
|
||||
"WebSocketUrl": "ws://localhost:3271/ws",
|
||||
"AuthToken": ""
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:4200",
|
||||
"http://localhost:5000"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"ControlCenter": "Debug"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"Gateway": {
|
||||
"WebSocketUrl": "ws://localhost:3271/ws",
|
||||
"AuthToken": ""
|
||||
},
|
||||
|
||||
"Cors": {
|
||||
"AllowedOrigins": [
|
||||
"http://localhost:4200",
|
||||
"http://localhost:5000"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using ControlCenter.Api.Configurations;
|
||||
using ControlCenter.Api.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ControlCenter.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core DbContext for the Control Center database.
|
||||
/// All table and column names use snake_case via explicit HasColumnName configuration.
|
||||
/// </summary>
|
||||
public class AppDbContext : DbContext
|
||||
{
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Agent> Agents => Set<Agent>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// Apply all entity type configurations from the Configurations namespace
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AgentConfiguration).Assembly);
|
||||
|
||||
// Map the AgentStatus enum to a PostgreSQL enum type named "agent_status"
|
||||
// This must be called after ApplyConfigurations to ensure the model is built
|
||||
// before the enum mapping is applied.
|
||||
modelBuilder.HasPostgresEnum<AgentStatus>();
|
||||
|
||||
base.OnModelCreating(modelBuilder);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ControlCenter.Api.Entities;
|
||||
|
||||
namespace ControlCenter.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory for AppDbContext, used by EF Core tools (dotnet ef)
|
||||
/// to create migrations without requiring a running application.
|
||||
/// </summary>
|
||||
public class AppDbContextFactory : Microsoft.EntityFrameworkCore.Design.IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
|
||||
|
||||
// Connection string for design-time operations (migrations).
|
||||
// In production, this comes from appsettings / environment variables.
|
||||
var connectionString = "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
|
||||
|
||||
optionsBuilder.UseNpgsql(connectionString, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
|
||||
});
|
||||
|
||||
return new AppDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
namespace ControlCenter.Api.Dtos;
|
||||
|
||||
/// <summary>
|
||||
/// Data transfer object for broadcasting agent status updates
|
||||
/// to all connected SignalR clients.
|
||||
/// </summary>
|
||||
public class AgentStatusUpdateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Agent identifier, e.g. "otto", "dex", "rex".
|
||||
/// Not null — every update must identify the agent it refers to.
|
||||
/// </summary>
|
||||
public string AgentId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable display name, e.g. "Otto", "Dex".
|
||||
/// Not null — used by clients to render agent cards.
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
|
||||
/// Not null — provides context for the agent's function.
|
||||
/// </summary>
|
||||
public string Role { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Current operational status of the agent.
|
||||
/// Maps to <see cref="Entities.AgentStatus"/> values as lowercase strings:
|
||||
/// "active", "idle", "thinking", "error".
|
||||
/// </summary>
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the agent's current task, if any.
|
||||
/// Null when the agent is idle with no active task.
|
||||
/// </summary>
|
||||
public string? CurrentTask { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Task progress percentage (0–100).
|
||||
/// Null when progress is not trackable for the current task.
|
||||
/// </summary>
|
||||
public int? TaskProgress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Elapsed time string for the current task, e.g. "04m 12s".
|
||||
/// Null when no task is active.
|
||||
/// </summary>
|
||||
public string? TaskElapsed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||
/// Not null — uniquely identifies the agent session.
|
||||
/// </summary>
|
||||
public string SessionKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
|
||||
/// Not null — every agent session operates on exactly one channel.
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISO 8601 timestamp of the agent's last activity.
|
||||
/// Not null — used by clients to detect stale connections.
|
||||
/// </summary>
|
||||
public string LastActivity { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Error message when the agent status is "error".
|
||||
/// Null when the agent is not in an error state.
|
||||
/// </summary>
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
namespace ControlCenter.Api.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an agent's current state in the Control Center.
|
||||
/// Each row tracks one agent session's status, task, and activity.
|
||||
/// </summary>
|
||||
public class Agent
|
||||
{
|
||||
/// <summary>
|
||||
/// Primary key — UUID generated on insert.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current operational status of the agent.
|
||||
/// Stored as an enum in PostgreSQL via Npgsql.
|
||||
/// </summary>
|
||||
public AgentStatus Status { get; set; } = AgentStatus.Idle;
|
||||
|
||||
/// <summary>
|
||||
/// Description of the agent's current task, if any.
|
||||
/// Nullable — not all agents have an active task.
|
||||
/// </summary>
|
||||
public string? Task { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Task progress percentage (0–100).
|
||||
/// Nullable — progress is only meaningful when an agent has a trackable task.
|
||||
/// </summary>
|
||||
public int? Progress { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
|
||||
/// Not null — every agent row must be associated with a session.
|
||||
/// </summary>
|
||||
public string SessionKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
|
||||
/// Not null — every agent session operates on exactly one channel.
|
||||
/// </summary>
|
||||
public string Channel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp of the agent's last activity.
|
||||
/// Default: current UTC timestamp on insert.
|
||||
/// </summary>
|
||||
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Row creation timestamp. Set automatically on insert.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Row last-update timestamp. Updated automatically on any modification.
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
namespace ControlCenter.Api.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Agent operational status enum.
|
||||
/// Maps to the agent_status enum type in PostgreSQL.
|
||||
/// </summary>
|
||||
public enum AgentStatus
|
||||
{
|
||||
Active = 0,
|
||||
Idle = 1,
|
||||
Thinking = 2,
|
||||
Error = 3
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
using ControlCenter.Api.Dtos;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ControlCenter.Api.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for broadcasting agent status updates to connected clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Clients call <see cref="SendStatusUpdate"/> to broadcast a status change,
|
||||
/// and the hub relays it to all connected clients via the
|
||||
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Server-side code should use <see cref="AgentStatusHubExtensions.PushStatusUpdateAsync"/>
|
||||
/// via <c>IHubContext<AgentStatusHub, IAgentStatusClient></c> for background-service broadcasts.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Architecture note: This hub bridges OpenClaw Gateway events to SignalR clients.
|
||||
/// A background service subscribes to Gateway events and pushes them through
|
||||
/// this hub's extension methods.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AgentStatusHub : Hub<IAgentStatusClient>
|
||||
{
|
||||
private readonly ILogger<AgentStatusHub> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentStatusHub"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger for diagnostic output.</param>
|
||||
public AgentStatusHub(ILogger<AgentStatusHub> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts an agent status update to all connected clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Any connected client (or server-side caller) can invoke this method
|
||||
/// to push a status update to every subscriber. The update is relayed
|
||||
/// through the <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="update">The agent status update payload to broadcast.</param>
|
||||
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Broadcasting status update for agent {AgentId}: {Status}",
|
||||
update.AgentId, update.Status);
|
||||
|
||||
await Clients.All.AgentStatusChanged(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the calling connection to the fleet group.
|
||||
/// Once joined, the client will receive all agent status updates.
|
||||
/// </summary>
|
||||
public async Task JoinFleet()
|
||||
{
|
||||
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the calling connection from the fleet group.
|
||||
/// </summary>
|
||||
public async Task LeaveFleet()
|
||||
{
|
||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
|
||||
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Overrides <see cref="Hub{T}.OnDisconnectedAsync"/> to log disconnections.
|
||||
/// SignalR automatically removes disconnected connections from all groups.
|
||||
/// </summary>
|
||||
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
||||
public override Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
|
||||
return base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SignalR group name for the entire fleet (all agents).
|
||||
/// </summary>
|
||||
internal const string FleetGroupName = "fleet";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed client interface for the AgentStatus SignalR hub.
|
||||
/// Defines the methods the server can invoke on connected clients
|
||||
/// to push real-time agent status updates.
|
||||
/// </summary>
|
||||
public interface IAgentStatusClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status change to all subscribed clients.
|
||||
/// Fired whenever an agent's operational status changes
|
||||
/// (e.g., idle → active, active → thinking, active → error).
|
||||
/// </summary>
|
||||
/// <param name="update">The full status update payload.</param>
|
||||
/// <returns>A Task that completes when the client has processed the update.</returns>
|
||||
Task AgentStatusChanged(AgentStatusUpdateDto update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for pushing real-time agent updates through
|
||||
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// These methods are intended to be called from background services
|
||||
/// or other server-side code that detects an agent state change,
|
||||
/// using the injected <c>IHubContext<AgentStatusHub, IAgentStatusClient></c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AgentStatusHubExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Pushes an agent status update to all connected clients.
|
||||
///
|
||||
/// <para>
|
||||
/// Call this from any background service when an agent's
|
||||
/// operational status changes (e.g., the Gateway reports a
|
||||
/// session transition from "running" to "done").
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="update">The agent status update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to all clients.</returns>
|
||||
public static async Task PushStatusUpdateAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
AgentStatusUpdateDto update)
|
||||
{
|
||||
await hubContext.Clients.All.AgentStatusChanged(update);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an agent status update to clients subscribed to the fleet group.
|
||||
/// </summary>
|
||||
/// <param name="hubContext">The hub context injected via DI.</param>
|
||||
/// <param name="update">The agent status update payload.</param>
|
||||
/// <returns>A Task that completes when the message has been sent to the fleet group.</returns>
|
||||
public static async Task PushStatusUpdateToFleetAsync(
|
||||
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
||||
AgentStatusUpdateDto update)
|
||||
{
|
||||
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
||||
.AgentStatusChanged(update);
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ControlCenter.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ControlCenter.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260426101703_CreateAgentsTable")]
|
||||
partial class CreateAgentsTable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTime>("LastActivity")
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("last_activity");
|
||||
|
||||
b.Property<int?>("Progress")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("progress");
|
||||
|
||||
b.Property<string>("SessionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("session_key");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("agent_status")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("Task")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("task");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Channel")
|
||||
.HasDatabaseName("ix_agents_channel");
|
||||
|
||||
b.HasIndex("SessionKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_agents_session_key");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("ix_agents_status");
|
||||
|
||||
b.ToTable("agents", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ControlCenter.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CreateAgentsTable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterDatabase()
|
||||
.Annotation("Npgsql:Enum:agent_status", "active,idle,thinking,error");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "agents",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
status = table.Column<int>(type: "agent_status", nullable: false),
|
||||
task = table.Column<string>(type: "text", nullable: true),
|
||||
progress = table.Column<int>(type: "integer", nullable: true),
|
||||
session_key = table.Column<string>(type: "text", nullable: false),
|
||||
channel = table.Column<string>(type: "text", nullable: false),
|
||||
last_activity = table.Column<DateTime>(type: "timestamptz", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_agents", x => x.id);
|
||||
table.CheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_agents_channel",
|
||||
table: "agents",
|
||||
column: "channel");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_agents_session_key",
|
||||
table: "agents",
|
||||
column: "session_key",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_agents_status",
|
||||
table: "agents",
|
||||
column: "status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "agents");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ControlCenter.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ControlCenter.Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.4")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<DateTime>("LastActivity")
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("last_activity");
|
||||
|
||||
b.Property<int?>("Progress")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("progress");
|
||||
|
||||
b.Property<string>("SessionKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("session_key");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("agent_status")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("Task")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("task");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamptz")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Channel")
|
||||
.HasDatabaseName("ix_agents_channel");
|
||||
|
||||
b.HasIndex("SessionKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_agents_session_key");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("ix_agents_status");
|
||||
|
||||
b.ToTable("agents", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using ControlCenter.Api.Data;
|
||||
using ControlCenter.Api.Hubs;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Register SignalR for real-time agent status updates
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Register DbContext with PostgreSQL
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
|
||||
|
||||
options.UseNpgsql(connectionString, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Map SignalR hubs
|
||||
app.MapHub<AgentStatusHub>("/hubs/agent-status");
|
||||
|
||||
app.Run();
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5178",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7041;http://localhost:5178",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=control_center;Username=postgres;Password=postgres"
|
||||
}
|
||||
}
|
||||
88
docker-compose.yml
Normal file
88
docker-compose.yml
Normal file
@@ -0,0 +1,88 @@
|
||||
# =============================================================================
|
||||
# Control Center — Full-Stack Docker Compose
|
||||
# =============================================================================
|
||||
# Boot the entire stack: PostgreSQL + Go backend + React frontend.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # fill in values
|
||||
# docker compose up --build # first run (builds images)
|
||||
# docker compose up # subsequent runs
|
||||
# docker compose down -v # teardown including DB volume
|
||||
# =============================================================================
|
||||
|
||||
name: control-center
|
||||
|
||||
services:
|
||||
# ── PostgreSQL ──────────────────────────────────────────────────────────────
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: cc-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-controlcenter}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-controlcenter}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./go-backend/migrations:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-controlcenter} -d ${POSTGRES_DB:-controlcenter}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
# ── Go Backend ──────────────────────────────────────────────────────────────
|
||||
backend:
|
||||
build:
|
||||
context: ./go-backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cc-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
environment:
|
||||
PORT: "8080"
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-controlcenter}:${POSTGRES_PASSWORD:-changeme}@postgres:5432/${POSTGRES_DB:-controlcenter}?sslmode=disable
|
||||
CORS_ORIGIN: "${CORS_ORIGIN:-http://localhost:3000}"
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
ENVIRONMENT: ${ENVIRONMENT:-development}
|
||||
GATEWAY_URL: ${GATEWAY_URL:-http://openclaw-gateway:18789/api/agents}
|
||||
GATEWAY_POLL_INTERVAL: ${GATEWAY_POLL_INTERVAL:-5s}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
# ── React Frontend (nginx) ──────────────────────────────────────────────────
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: cc-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:80"
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- cc-network
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
cc-network:
|
||||
driver: bridge
|
||||
@@ -1,17 +0,0 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
58
frontend/.gitignore
vendored
58
frontend/.gitignore
vendored
@@ -1,44 +1,24 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.idea
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
frontend/.vscode/extensions.json
vendored
4
frontend/.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
20
frontend/.vscode/launch.json
vendored
20
frontend/.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
9
frontend/.vscode/mcp.json
vendored
9
frontend/.vscode/mcp.json
vendored
@@ -1,9 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://angular.dev/ai/mcp
|
||||
"servers": {
|
||||
"angular-cli": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@angular/cli", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
42
frontend/.vscode/tasks.json
vendored
42
frontend/.vscode/tasks.json
vendored
@@ -1,42 +0,0 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
38
frontend/Dockerfile
Normal file
38
frontend/Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# ============================================================
|
||||
# Control Center Frontend — Multi-stage Docker Build
|
||||
# React 19 + Vite + nginx for static serving + API proxy
|
||||
# ============================================================
|
||||
|
||||
# --- Build Stage ---
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer caching)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy source and build production bundle
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Runtime Stage ---
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
|
||||
# Remove default nginx config
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built React app from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expose HTTP port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check — confirm nginx is serving
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,59 +1,73 @@
|
||||
# Frontend
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.8.
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Development server
|
||||
Currently, two official plugins are available:
|
||||
|
||||
To start a local development server, run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
## Code scaffolding
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"frontend": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:class": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"skipTests": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"skipTests": true
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "frontend:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/eslint.config.js
Normal file
22
frontend/eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Control Center</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
frontend/nginx.conf
Normal file
41
frontend/nginx.conf
Normal file
@@ -0,0 +1,41 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# Cache static assets (Vite uses content hashes)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache hashed JS/CSS bundles
|
||||
location ~* \.(js|css)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8080/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# React SPA — all other routes fall back to index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
8124
frontend/package-lock.json
generated
8124
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.11.0",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.10",
|
||||
"@angular/cdk": "^21.2.8",
|
||||
"@angular/common": "^21.2.0",
|
||||
"@angular/compiler": "^21.2.0",
|
||||
"@angular/core": "^21.2.0",
|
||||
"@angular/forms": "^21.2.0",
|
||||
"@angular/material": "^21.2.8",
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"axios": "^1.16.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.8",
|
||||
"@angular/cli": "^21.2.8",
|
||||
"@angular/compiler-cli": "^21.2.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~5.9.2"
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
23
frontend/src/App.tsx
Normal file
23
frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import HubPage from './pages/HubPage'
|
||||
import LogsPage from './pages/LogsPage'
|
||||
import ProjectsPage from './pages/ProjectsPage'
|
||||
import SessionsPage from './pages/SessionsPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HubPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
],
|
||||
};
|
||||
@@ -1,344 +0,0 @@
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
<style>
|
||||
:host {
|
||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
||||
--french-violet: oklch(47.66% 0.246 305.88);
|
||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
||||
--hot-red: oklch(61.42% 0.238 15.34);
|
||||
--orange-red: oklch(63.32% 0.24 31.68);
|
||||
|
||||
--gray-900: oklch(19.37% 0.006 300.98);
|
||||
--gray-700: oklch(36.98% 0.014 302.71);
|
||||
--gray-400: oklch(70.9% 0.015 304.04);
|
||||
|
||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
||||
180deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
||||
90deg,
|
||||
var(--orange-red) 0%,
|
||||
var(--vivid-pink) 50%,
|
||||
var(--electric-violet) 100%
|
||||
);
|
||||
|
||||
--pill-accent: var(--bright-blue);
|
||||
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
box-sizing: border-box;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
display: block;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.125rem;
|
||||
color: var(--gray-900);
|
||||
font-weight: 500;
|
||||
line-height: 100%;
|
||||
letter-spacing: -0.125rem;
|
||||
margin: 0;
|
||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
main {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
box-sizing: inherit;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.angular-logo {
|
||||
max-width: 9.2rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
||||
margin-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.pill-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--pill-accent: var(--bright-blue);
|
||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
||||
color: var(--pill-accent);
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 0.375rem;
|
||||
border-radius: 2.75rem;
|
||||
border: 0;
|
||||
transition: background 0.3s ease;
|
||||
font-family: var(--inter-font);
|
||||
font-size: 0.875rem;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 1.4rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill:hover {
|
||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 1) {
|
||||
--pill-accent: var(--bright-blue);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 2) {
|
||||
--pill-accent: var(--electric-violet);
|
||||
}
|
||||
.pill-group .pill:nth-child(6n + 3) {
|
||||
--pill-accent: var(--french-violet);
|
||||
}
|
||||
|
||||
.pill-group .pill:nth-child(6n + 4),
|
||||
.pill-group .pill:nth-child(6n + 5),
|
||||
.pill-group .pill:nth-child(6n + 6) {
|
||||
--pill-accent: var(--hot-red);
|
||||
}
|
||||
|
||||
.pill-group svg {
|
||||
margin-inline-start: 0.25rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.73rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.social-links path {
|
||||
transition: fill 0.3s ease;
|
||||
fill: var(--gray-400);
|
||||
}
|
||||
|
||||
.social-links a:hover svg path {
|
||||
fill: var(--gray-900);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 650px) {
|
||||
.content {
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<div class="left-side">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 982 239"
|
||||
fill="none"
|
||||
class="angular-logo"
|
||||
>
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="c"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF41F8" />
|
||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="0"
|
||||
x2="982"
|
||||
y1="192"
|
||||
y2="192"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#F0060B" />
|
||||
<stop offset="0" stop-color="#F0070C" />
|
||||
<stop offset=".526" stop-color="#CC26D5" />
|
||||
<stop offset="1" stop-color="#7702FF" />
|
||||
</linearGradient>
|
||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h1>Hello, {{ title() }}</h1>
|
||||
<p>Congratulations! Your app is running. 🎉</p>
|
||||
</div>
|
||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
||||
<div class="right-side">
|
||||
<div class="pill-group">
|
||||
@for (item of [
|
||||
{ title: 'Explore the Docs', link: 'https://angular.dev' },
|
||||
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
|
||||
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
|
||||
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
|
||||
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
|
||||
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
|
||||
]; track item.title) {
|
||||
<a
|
||||
class="pill"
|
||||
[href]="item.link"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<span>{{ item.title }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="14"
|
||||
viewBox="0 -960 960 960"
|
||||
width="14"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a
|
||||
href="https://github.com/angular/angular"
|
||||
aria-label="Github"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Github"
|
||||
>
|
||||
<path
|
||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/angular"
|
||||
aria-label="X"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="X"
|
||||
>
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
||||
aria-label="Youtube"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<svg
|
||||
width="29"
|
||||
height="20"
|
||||
viewBox="0 0 29 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
alt="Youtube"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
||||
|
||||
|
||||
<router-outlet />
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { LayoutShellComponent } from './layout/layout-shell/layout-shell.component';
|
||||
import { HubPageComponent } from './pages/hub/hub-page.component';
|
||||
import { ProjectsPageComponent } from './pages/projects/projects-page.component';
|
||||
import { SessionsPageComponent } from './pages/sessions/sessions-page.component';
|
||||
import { LogsPageComponent } from './pages/logs/logs-page.component';
|
||||
import { SettingsPageComponent } from './pages/settings/settings-page.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LayoutShellComponent,
|
||||
children: [
|
||||
{ path: '', redirectTo: 'hub', pathMatch: 'full' },
|
||||
{ path: 'hub', component: HubPageComponent },
|
||||
{ path: 'projects', component: ProjectsPageComponent },
|
||||
{ path: 'sessions', component: SessionsPageComponent },
|
||||
{ path: 'logs', component: LogsPageComponent },
|
||||
{ path: 'settings', component: SettingsPageComponent },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,4 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet],
|
||||
template: `<router-outlet />`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class App {}
|
||||
@@ -1,24 +0,0 @@
|
||||
<nav class="bottom-nav" aria-label="Bottom navigation">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
class="bottom-nav__item"
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="bottom-nav__item--active"
|
||||
#rla="routerLinkActive"
|
||||
[attr.aria-label]="dest.label"
|
||||
[attr.aria-current]="rla.isActive ? 'page' : null"
|
||||
>
|
||||
<span class="bottom-nav__icon-wrapper">
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
</span>
|
||||
<span class="bottom-nav__label">{{ dest.label }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
@@ -1,76 +0,0 @@
|
||||
// ============================================================================
|
||||
// Bottom Navigation Bar — Mobile Navigation
|
||||
// Per spec Section 3.2: M3 NavigationBar pattern
|
||||
// Visible only on compact breakpoint (< 600px)
|
||||
// ============================================================================
|
||||
|
||||
.bottom-nav {
|
||||
display: none; // Hidden on desktop, shown on mobile via media query
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--cc-bottom-nav-height);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.bottom-nav__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
color: var(--cc-on-surface-variant);
|
||||
border-radius: 16px;
|
||||
transition: color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--status-active);
|
||||
background-color: var(--status-active-bg);
|
||||
|
||||
.bottom-nav__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
|
||||
.bottom-nav__item--active & {
|
||||
background-color: var(--status-active-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// Show bottom nav only on compact breakpoint
|
||||
@media (max-width: 599px) {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
/**
|
||||
* Bottom Navigation Bar for mobile (compact breakpoint).
|
||||
* Per spec Section 3.2: M3 NavigationBar, 3–5 destinations,
|
||||
* active destination uses M3 indicator pill.
|
||||
* Visible only on compact (< 600px) breakpoint.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-bottom-nav',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './bottom-nav.component.html',
|
||||
styleUrl: './bottom-nav.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BottomNavComponent {
|
||||
/** Show only first 5 destinations on bottom nav */
|
||||
protected readonly destinations = NAV_DESTINATIONS.slice(0, 5);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
<header class="header-bar" role="banner">
|
||||
<h1 class="header-bar__title">Command Hub</h1>
|
||||
|
||||
<div class="header-bar__actions">
|
||||
<!-- Live indicator -->
|
||||
<button
|
||||
class="header-bar__action-btn header-bar__live-btn"
|
||||
mat-icon-button
|
||||
[attr.aria-label]="isConnected() ? 'Connected — live' : 'Disconnected'"
|
||||
>
|
||||
<span
|
||||
class="header-bar__live-dot"
|
||||
[class.header-bar__live-dot--connected]="isConnected()"
|
||||
></span>
|
||||
<span class="header-bar__live-label">
|
||||
{{ isConnected() ? 'Live' : 'Offline' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="notificationCount()"
|
||||
[matBadgeHidden]="notificationCount() === 0"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
notifications
|
||||
</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Settings -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Settings"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,76 +0,0 @@
|
||||
// ============================================================================
|
||||
// Header Bar — Top App Bar
|
||||
// Per spec Section 3.1: 64px tall, M3 MediumTopAppBar on expanded
|
||||
// Section 3.2: SmallTopAppBar on mobile
|
||||
// ============================================================================
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--cc-header-height);
|
||||
padding: 0 var(--cc-section-padding);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.header-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-bar__action-btn {
|
||||
color: var(--cc-on-surface-variant) !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
background-color: var(--status-error);
|
||||
vertical-align: middle;
|
||||
|
||||
&--connected {
|
||||
background-color: var(--status-active);
|
||||
animation: pulse-active 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface-variant);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// Mobile: smaller title
|
||||
@media (max-width: 599px) {
|
||||
.header-bar {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-bar__live-label {
|
||||
display: none; // Space saving on mobile — dot alone is enough
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
|
||||
/**
|
||||
* Header Bar component for the Command Hub.
|
||||
* Per spec Section 3.1: 64px tall, app title + live indicator + notification bell + settings.
|
||||
* Uses M3 top app bar pattern.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-header-bar',
|
||||
standalone: true,
|
||||
imports: [MatIconModule, MatButtonModule, MatBadgeModule],
|
||||
templateUrl: './header-bar.component.html',
|
||||
styleUrl: './header-bar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HeaderBarComponent {
|
||||
protected readonly notificationCount = signal(3);
|
||||
protected readonly isConnected = signal(true);
|
||||
|
||||
// TODO: Wire up notification panel (spec Section 2: Notifications Panel)
|
||||
// TODO: Wire up settings navigation
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './nav-rail/nav-rail.component';
|
||||
export * from './bottom-nav/bottom-nav.component';
|
||||
export * from './header-bar/header-bar.component';
|
||||
export * from './layout-shell/layout-shell.component';
|
||||
@@ -1,17 +0,0 @@
|
||||
<div class="layout-shell">
|
||||
<!-- Desktop/Kiosk: Nav Rail on the left -->
|
||||
<app-nav-rail class="layout-shell__nav-rail" />
|
||||
|
||||
<div class="layout-shell__main">
|
||||
<!-- Header bar at top of content area -->
|
||||
<app-header-bar class="layout-shell__header" />
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<main class="layout-shell__content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Bottom Navigation Bar -->
|
||||
<app-bottom-nav class="layout-shell__bottom-nav" />
|
||||
</div>
|
||||
@@ -1,57 +0,0 @@
|
||||
// ============================================================================
|
||||
// Layout Shell — Adaptive layout container
|
||||
// Desktop: Nav Rail (left) + Main Content (right)
|
||||
// Mobile: Header + Content + Bottom Nav (stacked)
|
||||
// ============================================================================
|
||||
|
||||
.layout-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-background);
|
||||
}
|
||||
|
||||
.layout-shell__nav-rail {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; // Prevent flex overflow
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-shell__header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--cc-section-padding);
|
||||
}
|
||||
|
||||
.layout-shell__bottom-nav {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// Mobile: Stack layout vertically, add bottom padding for bottom nav
|
||||
@media (max-width: 599px) {
|
||||
.layout-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
// Account for bottom nav bar height
|
||||
padding-bottom: calc(var(--cc-bottom-nav-height) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
// Tablet: Ensure content padding accommodates collapsed nav rail
|
||||
@media (min-width: 600px) and (max-width: 1023px) {
|
||||
.layout-shell__content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NavRailComponent } from '../nav-rail/nav-rail.component';
|
||||
import { BottomNavComponent } from '../bottom-nav/bottom-nav.component';
|
||||
import { HeaderBarComponent } from '../header-bar/header-bar.component';
|
||||
|
||||
/**
|
||||
* Layout Shell — wraps the main content area with adaptive navigation.
|
||||
* Desktop/Kiosk: Nav Rail (left) + Header + Content
|
||||
* Mobile: Header + Content + Bottom Nav
|
||||
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-layout-shell',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent],
|
||||
templateUrl: './layout-shell.component.html',
|
||||
styleUrl: './layout-shell.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LayoutShellComponent {}
|
||||
@@ -1,44 +0,0 @@
|
||||
<aside
|
||||
class="nav-rail"
|
||||
[class.nav-rail--expanded]="expanded()"
|
||||
[attr.aria-label]="'Navigation'"
|
||||
>
|
||||
<!-- Header with OpenClaw brand -->
|
||||
<div class="nav-rail__header">
|
||||
<button
|
||||
class="nav-rail__toggle"
|
||||
(click)="toggleExpand()"
|
||||
[attr.aria-label]="expanded() ? 'Collapse navigation' : 'Expand navigation'"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__brand">OpenClaw</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation destinations -->
|
||||
<nav class="nav-rail__nav">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="nav-rail__item--active"
|
||||
[attr.aria-label]="dest.label"
|
||||
class="nav-rail__item"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__label">{{ dest.label }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -1,112 +0,0 @@
|
||||
// ============================================================================
|
||||
// Nav Rail — Desktop/Kiosk Navigation
|
||||
// Per spec Section 3.1: 72px collapsed / 256px expanded
|
||||
// Section 5.4: Spacing & Grid
|
||||
// ============================================================================
|
||||
|
||||
.nav-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--cc-nav-rail-collapsed-width);
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-right: 1px solid var(--cc-outline);
|
||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
|
||||
&--expanded {
|
||||
width: var(--cc-nav-rail-expanded-width);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 12px;
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.nav-rail__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--cc-on-surface);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__brand {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--status-active);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav-rail__nav {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
|
||||
// Override Angular Material list item styles for compact nav rail items
|
||||
--mat-list-list-item-one-line-vertical-gap: 4px;
|
||||
}
|
||||
|
||||
.nav-rail__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 0 12px;
|
||||
border-radius: 28px;
|
||||
margin: 2px 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--status-active-bg);
|
||||
color: var(--status-active);
|
||||
|
||||
.nav-rail__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// Responsive: Hide nav rail on mobile (bottom nav takes over)
|
||||
@media (max-width: 599px) {
|
||||
.nav-rail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, signal, HostListener } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-rail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './nav-rail.component.html',
|
||||
styleUrl: './nav-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavRailComponent {
|
||||
protected readonly destinations = NAV_DESTINATIONS;
|
||||
protected readonly expanded = signal(false);
|
||||
|
||||
@HostListener('mouseenter')
|
||||
onHoverIn(): void {
|
||||
this.expanded.set(true);
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
onHoverOut(): void {
|
||||
this.expanded.set(false);
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
this.expanded.update(v => !v);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// ============================================================================
|
||||
// Agent Status Types
|
||||
// Per spec Section 7.3: Agent Card Component Interface
|
||||
// ============================================================================
|
||||
|
||||
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error' | 'offline';
|
||||
|
||||
export interface AgentCardData {
|
||||
/** Short agent ID, e.g., "otto" */
|
||||
id: string;
|
||||
|
||||
/** Display name, e.g., "Otto" */
|
||||
displayName: string;
|
||||
|
||||
/** Role description, e.g., "Orchestrator Agent" */
|
||||
role: string;
|
||||
|
||||
/** Current agent status */
|
||||
status: AgentStatus;
|
||||
|
||||
/** Current task description, e.g., "Reviewing PR #42" */
|
||||
currentTask?: string;
|
||||
|
||||
/** Task progress percentage 0–100 */
|
||||
taskProgress?: number;
|
||||
|
||||
/** Elapsed time string, e.g., "04m 12s" */
|
||||
taskElapsed?: string;
|
||||
|
||||
/** Full session key, e.g., "agent:otto:telegram:direct:8787..." */
|
||||
sessionKey: string;
|
||||
|
||||
/** Communication channel, e.g., "telegram" */
|
||||
channel: string;
|
||||
|
||||
/** Timestamp of last activity */
|
||||
lastActivity: Date;
|
||||
|
||||
/** Error message (populated only on error status) */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface AgentStatusUpdate {
|
||||
agentId: string;
|
||||
status: AgentStatus;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface TaskProgressUpdate {
|
||||
agentId: string;
|
||||
taskName?: string;
|
||||
progress: number;
|
||||
elapsed?: string;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './agent.model';
|
||||
export * from './nav.model';
|
||||
@@ -1,19 +0,0 @@
|
||||
// ============================================================================
|
||||
// Navigation Model
|
||||
// Per spec Section 3.5: Global Navigation Structure
|
||||
// ============================================================================
|
||||
|
||||
export interface NavDestination {
|
||||
label: string;
|
||||
icon: string;
|
||||
route: string;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
export const NAV_DESTINATIONS: NavDestination[] = [
|
||||
{ label: 'Command Hub', icon: 'bolt', route: '/hub' },
|
||||
{ label: 'Projects', icon: 'assignment', route: '/projects' },
|
||||
{ label: 'Sessions', icon: 'folder_open', route: '/sessions' },
|
||||
{ label: 'Logs', icon: 'bar_chart', route: '/logs' },
|
||||
{ label: 'Settings', icon: 'settings', route: '/settings' },
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-hub-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `
|
||||
<div class="hub-page">
|
||||
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.hub-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
.hub-page__placeholder {
|
||||
color: var(--cc-on-surface-variant);
|
||||
font-size: 16px;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HubPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Logs page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LogsPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Projects page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProjectsPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sessions-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Sessions page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SessionsPageComponent {}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-page',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
template: `<p style="color: var(--cc-on-surface-variant)">Settings page — coming soon</p>`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SettingsPageComponent {}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import { AgentCardData, AgentStatus, AgentStatusUpdate, TaskProgressUpdate } from '../models/agent.model';
|
||||
|
||||
/**
|
||||
* Agent Status Service — stub for future SignalR integration.
|
||||
* Per spec Section 7.4: Connects to /hubs/agent-status for real-time updates.
|
||||
*
|
||||
* TODO: Implement SignalR hub connection when backend is ready.
|
||||
* TODO: Wire up NgRx store or signals for reactive state management.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AgentStatusService {
|
||||
/** Stub: list of agents (will come from SignalR) */
|
||||
private readonly _agents = signal<AgentCardData[]>([]);
|
||||
|
||||
readonly agents = this._agents.asReadonly();
|
||||
|
||||
/** Stub: update an agent's status */
|
||||
updateStatus(update: AgentStatusUpdate): void {
|
||||
this._agents.update(agents =>
|
||||
agents.map(agent =>
|
||||
agent.id === update.agentId
|
||||
? { ...agent, status: update.status }
|
||||
: agent
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Stub: update an agent's task progress */
|
||||
updateTaskProgress(progress: TaskProgressUpdate): void {
|
||||
this._agents.update(agents =>
|
||||
agents.map(agent =>
|
||||
agent.id === progress.agentId
|
||||
? {
|
||||
...agent,
|
||||
taskProgress: progress.progress,
|
||||
...(progress.taskName ? { currentTask: progress.taskName } : {}),
|
||||
...(progress.elapsed ? { taskElapsed: progress.elapsed } : {}),
|
||||
}
|
||||
: agent
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: connect() — Initialize SignalR connection
|
||||
// TODO: disconnect() — Clean up SignalR connection
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
48
frontend/src/components/ErrorBoundary.tsx
Normal file
48
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, info)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-darkest p-4">
|
||||
<div className="max-w-md w-full p-6 rounded-xl border border-danger/30 bg-danger/10 text-center">
|
||||
<h2 className="text-xl font-bold text-danger mb-2">Something went wrong</h2>
|
||||
<p className="text-on-surface-variant text-sm mb-4">
|
||||
{this.state.error?.message || 'An unexpected error occurred.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 rounded-lg bg-primary text-surface-darkest font-medium"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
110
frontend/src/components/Layout.tsx
Normal file
110
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Command, label: 'Hub' },
|
||||
{ to: '/logs', icon: Activity, label: 'Logs' },
|
||||
{ to: '/projects', icon: FolderKanban, label: 'Projects' },
|
||||
{ to: '/sessions', icon: Monitor, label: 'Sessions' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-surface-darkest text-on-surface">
|
||||
{/* Desktop Nav Rail */}
|
||||
<aside
|
||||
className={`hidden md:flex flex-col border-r border-surface-light transition-all duration-200 ${
|
||||
expanded ? 'w-64' : 'w-18'
|
||||
}`}
|
||||
onMouseEnter={() => setExpanded(true)}
|
||||
onMouseLeave={() => setExpanded(false)}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-surface-light">
|
||||
<Command size={24} className="text-primary shrink-0" />
|
||||
{expanded && <span className="font-bold text-lg whitespace-nowrap">Control Center</span>}
|
||||
</div>
|
||||
<nav className="flex-1 py-4 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 mx-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} className="shrink-0" />
|
||||
{expanded && <span className="whitespace-nowrap">{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Mobile Header + Bottom Nav */}
|
||||
<div className="flex-1 flex flex-col md:ml-0">
|
||||
<header className="md:hidden flex items-center justify-between h-16 px-4 border-b border-surface-light bg-surface-dark">
|
||||
<div className="flex items-center gap-2">
|
||||
<Command size={22} className="text-primary" />
|
||||
<span className="font-bold">Control Center</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2">
|
||||
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-50 bg-surface-dark/95 backdrop-blur">
|
||||
<div className="flex flex-col p-4 space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-3 rounded-lg ${
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-on-surface-variant'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-4 md:p-6 overflow-auto">{children}</main>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<nav className="md:hidden flex items-center justify-around h-16 border-t border-surface-light bg-surface-dark">
|
||||
{navItems.slice(0, 5).map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`flex flex-col items-center gap-1 p-2 text-xs ${
|
||||
isActive ? 'text-primary' : 'text-on-surface-variant'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon size={20} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
frontend/src/hooks/useLocalStorage.ts
Normal file
29
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item !== null ? (JSON.parse(item) as T) : initialValue
|
||||
} catch {
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(storedValue))
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
}, [key, storedValue])
|
||||
|
||||
const setValue = useCallback((value: T | ((prev: T) => T)) => {
|
||||
setStoredValue((prev) => {
|
||||
const next = value instanceof Function ? value(prev) : value
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return [storedValue, setValue]
|
||||
}
|
||||
50
frontend/src/hooks/useTheme.tsx
Normal file
50
frontend/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
const stored = localStorage.getItem('cc-theme')
|
||||
if (stored === 'light' || stored === 'dark') return stored
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light')
|
||||
} else {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('cc-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
76
frontend/src/index.css
Normal file
76
frontend/src/index.css
Normal file
@@ -0,0 +1,76 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface-darkest: #0D0F12;
|
||||
--color-surface-dark: #13161A;
|
||||
--color-surface-medium: #1C2027;
|
||||
--color-surface-light: #252B33;
|
||||
--color-surface-lighter: #2D3748;
|
||||
--color-on-surface: #E2E8F0;
|
||||
--color-on-surface-variant: #8A9BB0;
|
||||
--color-on-surface-muted: #64748B;
|
||||
--color-primary: #38BDF8;
|
||||
--color-secondary: #2DD4BF;
|
||||
--color-accent: #A78BFA;
|
||||
--color-danger: #F87171;
|
||||
--color-status-active: #38BDF8;
|
||||
--color-status-idle: #2DD4BF;
|
||||
--color-status-thinking: #A78BFA;
|
||||
--color-status-error: #F87171;
|
||||
--color-status-offline: #64748B;
|
||||
|
||||
/* Light mode overrides */
|
||||
--color-light-surface-darkest: #F8FAFC;
|
||||
--color-light-surface-dark: #F1F5F9;
|
||||
--color-light-surface-medium: #E2E8F0;
|
||||
--color-light-surface-light: #CBD5E1;
|
||||
--color-light-surface-lighter: #94A3B8;
|
||||
--color-light-on-surface: #0F172A;
|
||||
--color-light-on-surface-variant: #475569;
|
||||
--color-light-on-surface-muted: #64748B;
|
||||
--color-light-primary: #0284C7;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-surface-darkest);
|
||||
color: var(--color-on-surface);
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
/* Dark theme (default) */
|
||||
html.dark body {
|
||||
background-color: var(--color-surface-darkest);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
html.light body {
|
||||
background-color: var(--color-light-surface-darkest);
|
||||
color: var(--color-light-on-surface);
|
||||
}
|
||||
|
||||
html.light {
|
||||
--color-surface-darkest: var(--color-light-surface-darkest);
|
||||
--color-surface-dark: var(--color-light-surface-dark);
|
||||
--color-surface-medium: var(--color-light-surface-medium);
|
||||
--color-surface-light: var(--color-light-surface-light);
|
||||
--color-surface-lighter: var(--color-light-surface-lighter);
|
||||
--color-on-surface: var(--color-light-on-surface);
|
||||
--color-on-surface-variant: var(--color-light-on-surface-variant);
|
||||
--color-on-surface-muted: var(--color-light-on-surface-muted);
|
||||
--color-primary: var(--color-light-primary);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--color-surface-light) 25%, var(--color-surface-lighter) 50%, var(--color-surface-light) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenClaw Control Center</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
<!-- Google Fonts: Inter (UI) + Roboto Mono (logs/session IDs) -->
|
||||
<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=Inter:wght@300;400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!-- Material Symbols (Outlined) per spec Section 5.5 -->
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +0,0 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
32
frontend/src/main.tsx
Normal file
32
frontend/src/main.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { ThemeProvider } from './hooks/useTheme'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
198
frontend/src/pages/HubPage.tsx
Normal file
198
frontend/src/pages/HubPage.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listAgents } from '../services/api'
|
||||
import { Activity, AlertTriangle, RefreshCw, Bot, Zap, Coffee, AlertCircle } from 'lucide-react'
|
||||
import type { Agent } from '../types'
|
||||
|
||||
function statusStats(agents: Agent[]) {
|
||||
const counts = { total: agents.length, active: 0, idle: 0, thinking: 0, error: 0 }
|
||||
for (const a of agents) {
|
||||
if (a.status in counts) counts[a.status as keyof typeof counts]++
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: 'bg-green-500',
|
||||
idle: 'bg-yellow-500',
|
||||
thinking: 'bg-blue-500',
|
||||
error: 'bg-red-500',
|
||||
}
|
||||
|
||||
export default function HubPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading, error, refetch, isRefetching } = useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: listAgents,
|
||||
})
|
||||
|
||||
const agents = data?.data ?? []
|
||||
const stats = statusStats(agents)
|
||||
|
||||
if (isLoading) {
|
||||
return <HubSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load agents</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['agents'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Command Hub</h1>
|
||||
<p className="text-on-surface-variant">Agent fleet overview</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw size={16} className={isRefetching ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<StatCard icon={Bot} label="Total" value={stats.total} color="text-on-surface" />
|
||||
<StatCard icon={Zap} label="Active" value={stats.active} color="text-green-500" />
|
||||
<StatCard icon={Coffee} label="Idle" value={stats.idle} color="text-yellow-500" />
|
||||
<StatCard icon={AlertTriangle} label="Errors" value={stats.error} color="text-red-500" />
|
||||
</div>
|
||||
|
||||
{/* Agent grid */}
|
||||
{agents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<Bot size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No agents registered</p>
|
||||
<p className="text-on-surface-muted text-sm">Agents will appear here once connected.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
|
||||
>
|
||||
{/* Agent identity */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-surface-light flex items-center justify-center text-lg font-bold shrink-0">
|
||||
{agent.displayName.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{agent.displayName}</h3>
|
||||
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={agent.status} />
|
||||
</div>
|
||||
|
||||
{/* Current task */}
|
||||
{agent.currentTask && (
|
||||
<div className="mb-2 text-sm text-on-surface-variant truncate">
|
||||
{agent.currentTask}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
{agent.taskProgress !== undefined && agent.taskProgress > 0 && (
|
||||
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(agent.taskProgress, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
||||
<Activity size={12} />
|
||||
<span>{agent.channel}</span>
|
||||
<span>·</span>
|
||||
<span>{agent.lastActivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="p-4 rounded-xl border border-surface-light bg-surface-dark flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-surface-light ${color}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-on-surface-variant">{label}</p>
|
||||
<p className="text-xl font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
|
||||
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
|
||||
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HubSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-48 skeleton mb-2" />
|
||||
<div className="h-4 w-36 skeleton" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg skeleton" />
|
||||
<div className="flex-1">
|
||||
<div className="h-3 w-12 skeleton mb-2" />
|
||||
<div className="h-6 w-8 skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<div className="h-10 w-10 rounded-full skeleton shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-24 skeleton mb-1" />
|
||||
<div className="h-3 w-16 skeleton" />
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded-full skeleton" />
|
||||
</div>
|
||||
<div className="h-4 w-full skeleton mb-2" />
|
||||
<div className="h-2 w-full skeleton rounded-full" />
|
||||
<div className="mt-3 h-3 w-32 skeleton" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
frontend/src/pages/LogsPage.tsx
Normal file
182
frontend/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listTasks } from '../services/api'
|
||||
import { AlertCircle, RefreshCw, Filter, CheckCircle, Circle, Clock, XCircle, Loader, ListTodo } from 'lucide-react'
|
||||
import type { Task } from '../types'
|
||||
|
||||
const STATUS_FILTERS = ['all', 'pending', 'running', 'completed', 'failed'] as const
|
||||
type StatusFilter = (typeof STATUS_FILTERS)[number]
|
||||
|
||||
const STATUS_ICON: Record<string, React.ElementType> = {
|
||||
pending: Clock,
|
||||
running: Loader,
|
||||
completed: CheckCircle,
|
||||
failed: XCircle,
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'text-yellow-500',
|
||||
running: 'text-blue-400',
|
||||
completed: 'text-green-500',
|
||||
failed: 'text-red-500',
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: listTasks,
|
||||
})
|
||||
|
||||
const tasks = (data?.data ?? []) as Task[]
|
||||
|
||||
const filtered = statusFilter === 'all'
|
||||
? tasks
|
||||
: tasks.filter((t) => t.status === statusFilter)
|
||||
|
||||
// Sort newest first
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
|
||||
if (isLoading) return <LogsSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load activity logs</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['tasks'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Activity Logs</h1>
|
||||
<p className="text-on-surface-variant">Task activity across all agents</p>
|
||||
</header>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<Filter size={16} className="text-on-surface-muted mr-1" />
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm capitalize transition-colors ${
|
||||
statusFilter === f
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-on-surface-variant hover:bg-surface-light hover:text-on-surface'
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activity feed */}
|
||||
{sorted.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<ListTodo size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No tasks found</p>
|
||||
<p className="text-on-surface-muted text-sm">
|
||||
{statusFilter === 'all' ? 'Tasks will appear here as agents execute work.' : `No ${statusFilter} tasks.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sorted.map((task) => {
|
||||
const Icon = STATUS_ICON[task.status] ?? Circle
|
||||
const fmtTime = formatTime(task.createdAt)
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors"
|
||||
>
|
||||
<div className={`mt-0.5 shrink-0 ${STATUS_COLOR[task.status] ?? 'text-on-surface-muted'}`}>
|
||||
<Icon size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{task.title}</p>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">
|
||||
Agent {task.agentId}
|
||||
{task.sessionKey && ` · ${task.sessionKey}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
<span className={`text-xs capitalize px-2 py-0.5 rounded-full font-medium ${
|
||||
statusFilter !== 'all'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-surface-light text-on-surface-variant'
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
{task.progress != null && task.progress > 0 && task.progress < 100 && (
|
||||
<span className="text-xs text-on-surface-muted">{task.progress}%</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-on-surface-muted whitespace-nowrap">
|
||||
{fmtTime}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - d.getTime()
|
||||
const diffMin = Math.floor(diffMs / 60_000)
|
||||
if (diffMin < 1) return 'just now'
|
||||
if (diffMin < 60) return `${diffMin}m ago`
|
||||
const diffHr = Math.floor(diffMin / 60)
|
||||
if (diffHr < 24) return `${diffHr}h ago`
|
||||
return d.toLocaleDateString()
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function LogsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-44 skeleton mb-2" />
|
||||
<div className="h-4 w-56 skeleton" />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-8 w-20 rounded-lg skeleton" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-4 p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="h-5 w-5 rounded-full skeleton shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-3/4 skeleton mb-1" />
|
||||
<div className="h-3 w-1/2 skeleton" />
|
||||
</div>
|
||||
<div className="h-6 w-20 rounded-full skeleton" />
|
||||
<div className="h-3 w-16 skeleton" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
117
frontend/src/pages/ProjectsPage.tsx
Normal file
117
frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listProjects } from '../services/api'
|
||||
import { AlertCircle, RefreshCw, FolderKanban, Users } from 'lucide-react'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
planned: 'bg-purple-500',
|
||||
active: 'bg-green-500',
|
||||
paused: 'bg-yellow-500',
|
||||
completed: 'bg-blue-400',
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: listProjects,
|
||||
})
|
||||
|
||||
const projects = data?.data ?? []
|
||||
|
||||
if (isLoading) return <ProjectsSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load projects</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['projects'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<p className="text-on-surface-variant">Tracked projects and initiatives</p>
|
||||
</header>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<FolderKanban size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No projects tracked</p>
|
||||
<p className="text-on-surface-muted text-sm">Projects synced from Linear will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
className="p-5 rounded-xl border border-surface-light bg-surface-dark hover:border-surface-lighter transition-colors flex flex-col"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<FolderKanban size={20} className="text-on-surface-variant shrink-0 mt-0.5" />
|
||||
<ProjectStatusBadge status={project.status} />
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold mb-1">{project.name}</h3>
|
||||
{project.description && (
|
||||
<p className="text-sm text-on-surface-variant mb-4 line-clamp-2 flex-1">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-on-surface-muted pt-3 border-t border-surface-light">
|
||||
<Users size={14} />
|
||||
<span>{project.agentIds?.length ?? 0} agent{(project.agentIds?.length ?? 0) !== 1 ? 's' : ''} assigned</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-surface-light/50">
|
||||
<div className={`w-2 h-2 rounded-full ${STATUS_COLORS[status] ?? 'bg-gray-500'}`} />
|
||||
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-40 skeleton mb-2" />
|
||||
<div className="h-4 w-56 skeleton" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="p-5 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex justify-between mb-3">
|
||||
<div className="h-5 w-5 rounded skeleton" />
|
||||
<div className="h-6 w-20 rounded-full skeleton" />
|
||||
</div>
|
||||
<div className="h-5 w-3/4 skeleton mb-2" />
|
||||
<div className="h-4 w-full skeleton mb-2" />
|
||||
<div className="h-4 w-2/3 skeleton mb-4" />
|
||||
<div className="pt-3 border-t border-surface-light">
|
||||
<div className="h-3 w-32 skeleton" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
frontend/src/pages/SessionsPage.tsx
Normal file
177
frontend/src/pages/SessionsPage.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listSessions } from '../services/api'
|
||||
import { AlertCircle, Monitor, RefreshCw, Cpu, MessageSquare, Clock, Hash } from 'lucide-react'
|
||||
|
||||
export default function SessionsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['sessions'],
|
||||
queryFn: listSessions,
|
||||
})
|
||||
|
||||
const sessions = data?.data ?? []
|
||||
|
||||
if (isLoading) return <SessionsSkeleton />
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle size={48} className="text-danger" />
|
||||
<p className="text-danger text-lg">Failed to load sessions</p>
|
||||
<button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['sessions'] })}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Sessions</h1>
|
||||
<p className="text-on-surface-variant">Active and recent agent sessions</p>
|
||||
</header>
|
||||
|
||||
{sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 gap-3 border border-dashed border-surface-light rounded-xl">
|
||||
<Monitor size={40} className="text-on-surface-muted" />
|
||||
<p className="text-on-surface-muted text-lg">No active sessions</p>
|
||||
<p className="text-on-surface-muted text-sm">Sessions will appear when agents connect.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop: Table view */}
|
||||
<div className="hidden lg:block overflow-x-auto rounded-xl border border-surface-light">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-light bg-surface-dark">
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Agent</th>
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Session Key</th>
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Channel</th>
|
||||
<th className="text-left p-4 font-medium text-on-surface-variant">Model</th>
|
||||
<th className="text-right p-4 font-medium text-on-surface-variant">Context Tokens</th>
|
||||
<th className="text-right p-4 font-medium text-on-surface-variant">Started</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((s) => (
|
||||
<tr
|
||||
key={s.id}
|
||||
className="border-b border-surface-light hover:bg-surface-dark/50 transition-colors"
|
||||
>
|
||||
<td className="p-4 font-medium">{s.agentId}</td>
|
||||
<td className="p-4">
|
||||
<code className="text-xs bg-surface-light px-2 py-1 rounded text-on-surface-variant">
|
||||
{s.sessionKey}
|
||||
</code>
|
||||
</td>
|
||||
<td className="p-4 text-on-surface-variant">{s.channel}</td>
|
||||
<td className="p-4 text-on-surface-variant">{s.model}</td>
|
||||
<td className="p-4 text-right tabular-nums text-on-surface-variant">
|
||||
{s.contextTokens.toLocaleString()}
|
||||
</td>
|
||||
<td className="p-4 text-right text-on-surface-muted whitespace-nowrap">
|
||||
{formatDateTime(s.startedAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Card view */}
|
||||
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{sessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Monitor size={16} className="text-primary" />
|
||||
<span className="font-medium text-sm">{s.agentId}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<Row icon={Hash} label="Session">{s.sessionKey}</Row>
|
||||
<Row icon={MessageSquare} label="Channel">{s.channel}</Row>
|
||||
<Row icon={Cpu} label="Model">{s.model}</Row>
|
||||
<Row icon={Hash} label="Tokens">{s.contextTokens.toLocaleString()}</Row>
|
||||
<Row icon={Clock} label="Started">{formatDateTime(s.startedAt)}</Row>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon: Icon, label, children }: { icon: React.ElementType; label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={14} className="text-on-surface-muted shrink-0" />
|
||||
<span className="text-on-surface-muted text-xs w-14 shrink-0">{label}</span>
|
||||
<span className="text-on-surface-variant truncate">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function SessionsSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-8 w-40 skeleton mb-2" />
|
||||
<div className="h-4 w-56 skeleton" />
|
||||
</div>
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block rounded-xl border border-surface-light overflow-hidden">
|
||||
<div className="bg-surface-dark p-4 border-b border-surface-light">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-3 skeleton" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="border-b border-surface-light p-4">
|
||||
<div className="grid grid-cols-6 gap-4">
|
||||
{[...Array(6)].map((_, j) => (
|
||||
<div key={j} className="h-4 skeleton" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile skeleton */}
|
||||
<div className="lg:hidden grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="h-4 w-24 skeleton mb-3" />
|
||||
{[...Array(5)].map((_, j) => (
|
||||
<div key={j} className="h-4 w-full skeleton mb-2" />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
frontend/src/pages/SettingsPage.tsx
Normal file
126
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||
import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react'
|
||||
|
||||
const REFRESH_PRESETS = [
|
||||
{ label: '5s', value: 5_000 },
|
||||
{ label: '10s', value: 10_000 },
|
||||
{ label: '30s', value: 30_000 },
|
||||
{ label: '60s', value: 60_000 },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
||||
const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000)
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-on-surface-variant">Application preferences</p>
|
||||
</header>
|
||||
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Monitor size={20} className="text-primary" />
|
||||
Appearance
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Dark Mode</p>
|
||||
<p className="text-sm text-on-surface-variant">Toggle between dark and light themes</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`relative w-14 h-8 rounded-full transition-colors duration-200 ${
|
||||
isDark ? 'bg-primary' : 'bg-surface-lighter'
|
||||
}`}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200 flex items-center justify-center ${
|
||||
isDark ? 'translate-x-7' : 'translate-x-1'
|
||||
}`}
|
||||
>
|
||||
{isDark ? <Moon size={14} className="text-surface-darkest" /> : <Sun size={14} className="text-yellow-500" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap size={20} className="text-primary" />
|
||||
Connection
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||
<div>
|
||||
<label htmlFor="gateway-url" className="block text-sm font-medium mb-1">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
id="gateway-url"
|
||||
type="url"
|
||||
value={gatewayUrl}
|
||||
onChange={(e) => setGatewayUrl(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
className="w-full px-3 py-2 rounded-lg border border-surface-light bg-surface-darkest text-on-surface placeholder:text-on-surface-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-on-surface-muted mt-1">
|
||||
The backend Gateway address for API requests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Refresh */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock size={20} className="text-primary" />
|
||||
Auto Refresh
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
step="1"
|
||||
value={REFRESH_PRESETS.findIndex((p) => p.value === refreshInterval)}
|
||||
onChange={(e) => {
|
||||
const idx = parseInt(e.target.value)
|
||||
setRefreshInterval(REFRESH_PRESETS[idx].value)
|
||||
}}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-on-surface-muted">
|
||||
{REFRESH_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => setRefreshInterval(p.value)}
|
||||
className={`px-3 py-1 rounded-lg transition-colors ${
|
||||
refreshInterval === p.value
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-surface-light'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
frontend/src/services/api.ts
Normal file
58
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios from 'axios'
|
||||
import type { Agent, Session, Task, Project, PaginatedResponse } from '../types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
// Health check
|
||||
export async function getHealth() {
|
||||
const res = await api.get('/health')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Agents
|
||||
export async function listAgents() {
|
||||
const res = await api.get<PaginatedResponse<Agent>>('/agents')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getAgent(id: string) {
|
||||
const res = await api.get<{ data: Agent }>(`/agents/${id}`)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function createAgent(data: Omit<Agent, 'lastActivity'>) {
|
||||
const res = await api.post<{ data: Agent }>('/agents', data)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function updateAgent(id: string, data: Partial<Agent>) {
|
||||
const res = await api.put<{ data: Agent }>(`/agents/${id}`, data)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string) {
|
||||
await api.delete(`/agents/${id}`)
|
||||
}
|
||||
|
||||
// Sessions
|
||||
export async function listSessions() {
|
||||
const res = await api.get<PaginatedResponse<Session>>('/sessions')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Tasks
|
||||
export async function listTasks() {
|
||||
const res = await api.get<PaginatedResponse<Task>>('/tasks')
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Projects
|
||||
export async function listProjects() {
|
||||
const res = await api.get<PaginatedResponse<Project>>('/projects')
|
||||
return res.data
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -1,212 +0,0 @@
|
||||
// ============================================================================
|
||||
// OpenClaw Control Center — M3 Tactical Dark Theme
|
||||
// ============================================================================
|
||||
// Material Design 3 theming with custom dark palette per design spec.
|
||||
// Section 5.1: Color Palette, Section 5.2: Typography
|
||||
// ============================================================================
|
||||
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3 Theme Definition
|
||||
// ---------------------------------------------------------------------------
|
||||
// Using mat.define-theme() with custom color tokens to match the tactical
|
||||
// dark palette. Angular Material 19+ uses the new theming API.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
$dark-theme: mat.define-theme((
|
||||
color: (
|
||||
theme-type: dark,
|
||||
primary: mat.$cyan-palette,
|
||||
tertiary: mat.$violet-palette,
|
||||
),
|
||||
typography: (
|
||||
brand-family: 'Inter, Roboto, sans-serif',
|
||||
plain-family: 'Inter, Roboto, sans-serif',
|
||||
bold-weight: 600,
|
||||
medium-weight: 500,
|
||||
regular-weight: 400,
|
||||
),
|
||||
density: (
|
||||
scale: 0,
|
||||
),
|
||||
));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply theme to :root
|
||||
// ---------------------------------------------------------------------------
|
||||
html {
|
||||
height: 100%;
|
||||
@include mat.theme($dark-theme);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom CSS Custom Properties — Status Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per spec Section 5.1 "Status Colors (Semantic — outside M3 tonal system)"
|
||||
// These are NOT part of the M3 tonal palette; they are semantic overrides.
|
||||
// ---------------------------------------------------------------------------
|
||||
:root {
|
||||
// --- Status colors ---
|
||||
--status-active: #38BDF8;
|
||||
--status-idle: #2DD4BF;
|
||||
--status-thinking: #A78BFA;
|
||||
--status-error: #F87171;
|
||||
--status-offline: #64748B;
|
||||
|
||||
// --- Status background tints (12% opacity) ---
|
||||
--status-active-bg: rgba(56, 189, 248, 0.12);
|
||||
--status-idle-bg: rgba(45, 212, 191, 0.12);
|
||||
--status-thinking-bg: rgba(167, 139, 250, 0.12);
|
||||
--status-error-bg: rgba(248, 113, 113, 0.12);
|
||||
|
||||
// --- Surface overrides (tactical dark palette) ---
|
||||
--cc-background: #0D0F12;
|
||||
--cc-surface: #13161A;
|
||||
--cc-surface-container: #1C2027;
|
||||
--cc-surface-container-high: #252B33;
|
||||
--cc-on-surface: #E2E8F0;
|
||||
--cc-on-surface-variant: #8A9BB0;
|
||||
--cc-outline: #2D3748;
|
||||
|
||||
// --- Mono font stack ---
|
||||
--cc-font-mono: 'Roboto Mono', 'Cascadia Code', 'Fira Code', monospace;
|
||||
|
||||
// --- Layout constants ---
|
||||
--cc-nav-rail-collapsed-width: 72px;
|
||||
--cc-nav-rail-expanded-width: 256px;
|
||||
--cc-header-height: 64px;
|
||||
--cc-bottom-nav-height: 80px;
|
||||
--cc-card-border-radius: 16px;
|
||||
--cc-card-min-width: 320px;
|
||||
--cc-card-gap: 16px;
|
||||
--cc-card-padding: 20px;
|
||||
--cc-section-padding: 24px;
|
||||
--cc-spacing-unit: 8px;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global Body Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
body {
|
||||
background-color: var(--cc-background);
|
||||
color: var(--cc-on-surface);
|
||||
font-family: 'Inter', 'Roboto', sans-serif;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3 Surface Overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override M3 surface tokens to match our tactical dark palette
|
||||
// ---------------------------------------------------------------------------
|
||||
:root {
|
||||
// Override M3 system color tokens to match custom palette
|
||||
--mat-sys-surface: var(--cc-surface);
|
||||
--mat-sys-surface-container: var(--cc-surface-container);
|
||||
--mat-sys-surface-container-high: var(--cc-surface-container-high);
|
||||
--mat-sys-on-surface: var(--cc-on-surface);
|
||||
--mat-sys-on-surface-variant: var(--cc-on-surface-variant);
|
||||
--mat-sys-outline: var(--cc-outline);
|
||||
--mat-sys-background: var(--cc-background);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
.text-mono {
|
||||
font-family: var(--cc-font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status Dot Pulse Animations
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per spec Section 7.5: Animation Specs
|
||||
// ---------------------------------------------------------------------------
|
||||
@keyframes pulse-active {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.7; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes pulse-thinking {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility Classes
|
||||
// ---------------------------------------------------------------------------
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
|
||||
&--active {
|
||||
background-color: var(--status-active);
|
||||
animation: pulse-active 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--idle {
|
||||
background-color: var(--status-idle);
|
||||
}
|
||||
|
||||
&--thinking {
|
||||
background-color: var(--status-thinking);
|
||||
animation: pulse-thinking 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--error {
|
||||
background-color: var(--status-error);
|
||||
animation: pulse-error 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&--offline {
|
||||
background-color: var(--status-offline);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility: Reduced Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status-dot--active,
|
||||
.status-dot--error,
|
||||
.status-dot--thinking {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scrollbar Styling (Tactical Dark)
|
||||
// ---------------------------------------------------------------------------
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--cc-surface);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--cc-outline);
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
background: var(--cc-on-surface-variant);
|
||||
}
|
||||
}
|
||||
64
frontend/src/types/index.ts
Normal file
64
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export type AgentStatus = 'active' | 'idle' | 'thinking' | 'error'
|
||||
|
||||
export interface Agent {
|
||||
id: string
|
||||
displayName: string
|
||||
role: string
|
||||
status: AgentStatus
|
||||
currentTask?: string
|
||||
taskProgress?: number
|
||||
taskElapsed?: string
|
||||
sessionKey: string
|
||||
channel: string
|
||||
lastActivity: string
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
sessionKey: string
|
||||
agentId: string
|
||||
channel: string
|
||||
status: string
|
||||
contextTokens: number
|
||||
totalTokens: number
|
||||
estimatedCost: number
|
||||
model: string
|
||||
startedAt: string
|
||||
lastActivityAt: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string
|
||||
agentId: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
progress?: number
|
||||
sessionKey: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
agentIds: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
totalCount: number
|
||||
page: number
|
||||
pageSize: number
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string
|
||||
details?: Record<string, string>
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user