Compare commits
68 Commits
7b7b070dac
...
agent/rex/
| Author | SHA1 | Date | |
|---|---|---|---|
| 5147997764 | |||
| 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 | ||
|
|
040d4cb54d | ||
|
|
47cbeed456 | ||
|
|
d2da0c160f | ||
|
|
bcaa526a69 | ||
|
|
14b3dab88b | ||
|
|
b4e110f4c3 | ||
|
|
d5a85c4ed0 | ||
| 8d0adeb2e9 | |||
| a79ab6dbd5 | |||
| 6668da04d4 | |||
|
|
6cf8d7fe5f | ||
|
|
69df1562c7 | ||
|
|
1c5d982cd9 |
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
|
||||
44
.gitea/workflows/dev.yml
Normal file
44
.gitea/workflows/dev.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
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 .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Restore backend
|
||||
run: dotnet restore
|
||||
working-directory: ./backend
|
||||
|
||||
- name: Build backend
|
||||
run: dotnet build --no-restore --configuration Release
|
||||
working-directory: ./backend
|
||||
|
||||
- name: Test backend
|
||||
run: dotnet test --no-build --configuration Release
|
||||
working-directory: ./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,18 +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" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +0,0 @@
|
||||
@ControlCenter.Api_HostAddress = http://localhost:5178
|
||||
|
||||
GET {{ControlCenter.Api_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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,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,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,31 +0,0 @@
|
||||
using ControlCenter.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// 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();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
142
frontend/src/components/Layout.tsx
Normal file
142
frontend/src/components/Layout.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Command, Activity, FolderKanban, Monitor, Settings, Menu, X, Wifi, WifiOff, Loader } from 'lucide-react'
|
||||
import { useSSEContext } from '../contexts/SSEContext'
|
||||
import type { SSEStatus } from '../hooks/useSSE'
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
/** Small status pill shown in the sidebar footer and mobile header. */
|
||||
function SSEStatusBadge({ status, showLabel = false }: { status: SSEStatus; showLabel?: boolean }) {
|
||||
const cfg = {
|
||||
connected: { icon: Wifi, color: 'text-green-500', label: 'Live' },
|
||||
connecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Connecting' },
|
||||
reconnecting: { icon: Loader, color: 'text-yellow-500 animate-spin', label: 'Reconnecting' },
|
||||
error: { icon: WifiOff, color: 'text-red-500', label: 'Disconnected' },
|
||||
}[status]
|
||||
|
||||
const Icon = cfg.icon
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title={cfg.label}>
|
||||
<Icon size={14} className={cfg.color} />
|
||||
{showLabel && <span className={`text-xs ${cfg.color}`}>{cfg.label}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const { sseStatus } = useSSEContext()
|
||||
|
||||
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>
|
||||
{/* SSE connection status — footer of sidebar */}
|
||||
<div className="px-4 py-3 border-t border-surface-light flex items-center gap-2">
|
||||
<SSEStatusBadge status={sseStatus} />
|
||||
{expanded && (
|
||||
<span className="text-xs text-on-surface-muted whitespace-nowrap">
|
||||
{sseStatus === 'connected' ? 'Live updates on' : sseStatus}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
<SSEStatusBadge status={sseStatus} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
23
frontend/src/contexts/SSEContext.tsx
Normal file
23
frontend/src/contexts/SSEContext.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* SSEContext — provides SSE connection status throughout the component tree.
|
||||
* Mount <SSEProvider> once inside QueryClientProvider.
|
||||
*/
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import { useRealtimeSync } from '../hooks/useRealtimeSync'
|
||||
import type { SSEStatus } from '../hooks/useSSE'
|
||||
|
||||
interface SSEContextValue {
|
||||
sseStatus: SSEStatus
|
||||
}
|
||||
|
||||
const SSEContext = createContext<SSEContextValue>({ sseStatus: 'connecting' })
|
||||
|
||||
export function SSEProvider({ children }: { children: ReactNode }) {
|
||||
const { sseStatus } = useRealtimeSync()
|
||||
return <SSEContext.Provider value={{ sseStatus }}>{children}</SSEContext.Provider>
|
||||
}
|
||||
|
||||
/** Access the SSE connection status from any component. */
|
||||
export function useSSEContext(): SSEContextValue {
|
||||
return useContext(SSEContext)
|
||||
}
|
||||
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]
|
||||
}
|
||||
52
frontend/src/hooks/useRealtimeSync.ts
Normal file
52
frontend/src/hooks/useRealtimeSync.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* useRealtimeSync — mounts the SSE connection once at the app level and
|
||||
* wires incoming events to React Query cache invalidation.
|
||||
*
|
||||
* Event → query key mapping:
|
||||
* agent.status → ['agents']
|
||||
* agent.task → ['tasks'], ['agents']
|
||||
* agent.progress → ['tasks'], ['agents']
|
||||
* fleet.update → ['agents'], ['sessions'], ['tasks']
|
||||
*/
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useSSE, type SSEMessage, type SSEStatus } from './useSSE'
|
||||
|
||||
export function useRealtimeSync(): { sseStatus: SSEStatus } {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(msg: SSEMessage) => {
|
||||
switch (msg.type) {
|
||||
case 'agent.status':
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
||||
break
|
||||
|
||||
case 'agent.task':
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
||||
break
|
||||
|
||||
case 'agent.progress':
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
||||
break
|
||||
|
||||
case 'fleet.update':
|
||||
queryClient.invalidateQueries({ queryKey: ['agents'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['sessions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
break
|
||||
|
||||
default:
|
||||
// 'connected' and unknown events — no action needed
|
||||
break
|
||||
}
|
||||
},
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
const { status: sseStatus } = useSSE({ onMessage: handleMessage })
|
||||
|
||||
return { sseStatus }
|
||||
}
|
||||
159
frontend/src/hooks/useSSE.ts
Normal file
159
frontend/src/hooks/useSSE.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
|
||||
/** SSE connection state reported to consumers. */
|
||||
export type SSEStatus = 'connecting' | 'connected' | 'reconnecting' | 'error'
|
||||
|
||||
/** Typed SSE event received from the backend. */
|
||||
export interface SSEMessage {
|
||||
/** event: field from the SSE frame */
|
||||
type: string
|
||||
/** parsed JSON from the data: field */
|
||||
data: unknown
|
||||
}
|
||||
|
||||
export interface UseSSEOptions {
|
||||
/** Endpoint URL — defaults to /api/events */
|
||||
url?: string
|
||||
/** Called for every SSE message (all event types) */
|
||||
onMessage?: (msg: SSEMessage) => void
|
||||
/** Called when connection opens or reconnects */
|
||||
onOpen?: () => void
|
||||
/** Called on unrecoverable error */
|
||||
onError?: (err: Event) => void
|
||||
/** Base delay in ms before the first reconnect attempt (default 1 000) */
|
||||
reconnectBaseMs?: number
|
||||
/** Maximum reconnect delay in ms (default 30 000) */
|
||||
reconnectMaxMs?: number
|
||||
/** Set false to disable auto-connect (useful in tests) */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const SSE_EVENTS = ['agent.status', 'agent.task', 'agent.progress', 'fleet.update', 'connected'] as const
|
||||
|
||||
/**
|
||||
* useSSE — mounts a persistent SSE connection to the Control Center backend.
|
||||
*
|
||||
* Handles:
|
||||
* - Initial connection on mount
|
||||
* - Exponential back-off reconnection on drop
|
||||
* - Cleanup on unmount
|
||||
* - All four event types: agent.status, agent.task, agent.progress, fleet.update
|
||||
*/
|
||||
export function useSSE({
|
||||
url = '/api/events',
|
||||
onMessage,
|
||||
onOpen,
|
||||
onError,
|
||||
reconnectBaseMs = 1_000,
|
||||
reconnectMaxMs = 30_000,
|
||||
enabled = true,
|
||||
}: UseSSEOptions = {}): { status: SSEStatus } {
|
||||
const [status, setStatus] = useState<SSEStatus>('connecting')
|
||||
|
||||
// Stable refs so the effect doesn't need to re-run when callbacks change
|
||||
const onMessageRef = useRef(onMessage)
|
||||
const onOpenRef = useRef(onOpen)
|
||||
const onErrorRef = useRef(onError)
|
||||
onMessageRef.current = onMessage
|
||||
onOpenRef.current = onOpen
|
||||
onErrorRef.current = onError
|
||||
|
||||
const reconnectAttemptRef = useRef(0)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const clearReconnectTimer = useCallback(() => {
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current || !enabled) return
|
||||
|
||||
// Clean up any existing connection
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
|
||||
setStatus(reconnectAttemptRef.current === 0 ? 'connecting' : 'reconnecting')
|
||||
|
||||
const es = new EventSource(url)
|
||||
esRef.current = es
|
||||
|
||||
es.onopen = () => {
|
||||
if (!mountedRef.current) return
|
||||
reconnectAttemptRef.current = 0
|
||||
setStatus('connected')
|
||||
onOpenRef.current?.()
|
||||
}
|
||||
|
||||
es.onerror = (evt) => {
|
||||
if (!mountedRef.current) return
|
||||
|
||||
// EventSource auto-retries but we manage our own to get back-off control
|
||||
es.close()
|
||||
esRef.current = null
|
||||
|
||||
onErrorRef.current?.(evt)
|
||||
|
||||
// Exponential back-off: 1s, 2s, 4s … capped at reconnectMaxMs
|
||||
const delay = Math.min(
|
||||
reconnectBaseMs * 2 ** reconnectAttemptRef.current,
|
||||
reconnectMaxMs,
|
||||
)
|
||||
reconnectAttemptRef.current += 1
|
||||
setStatus('reconnecting')
|
||||
|
||||
clearReconnectTimer()
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
// Register listeners for all known event types
|
||||
for (const eventType of SSE_EVENTS) {
|
||||
es.addEventListener(eventType, (evt: MessageEvent) => {
|
||||
if (!mountedRef.current) return
|
||||
let data: unknown = evt.data
|
||||
try {
|
||||
data = JSON.parse(evt.data as string)
|
||||
} catch {
|
||||
// leave as raw string
|
||||
}
|
||||
onMessageRef.current?.({ type: eventType, data })
|
||||
})
|
||||
}
|
||||
|
||||
// Catch-all for unnamed events (type == 'message')
|
||||
es.onmessage = (evt: MessageEvent) => {
|
||||
if (!mountedRef.current) return
|
||||
let data: unknown = evt.data
|
||||
try {
|
||||
data = JSON.parse(evt.data as string)
|
||||
} catch {
|
||||
// leave as raw string
|
||||
}
|
||||
onMessageRef.current?.({ type: 'message', data })
|
||||
}
|
||||
}, [url, enabled, reconnectBaseMs, reconnectMaxMs, clearReconnectTimer])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
if (enabled) connect()
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearReconnectTimer()
|
||||
if (esRef.current) {
|
||||
esRef.current.close()
|
||||
esRef.current = null
|
||||
}
|
||||
}
|
||||
}, [connect, enabled, clearReconnectTimer])
|
||||
|
||||
return { status }
|
||||
}
|
||||
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));
|
||||
39
frontend/src/main.tsx
Normal file
39
frontend/src/main.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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 { SSEProvider } from './contexts/SSEContext'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// No polling — real-time updates come through SSE.
|
||||
// staleTime is kept high; data is pushed, not pulled.
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{/* SSEProvider must live inside QueryClientProvider so it can call
|
||||
useQueryClient() to invalidate caches on incoming events. */}
|
||||
<SSEProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</SSEProvider>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
130
frontend/src/pages/SettingsPage.tsx
Normal file
130
frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||
import { useSSEContext } from '../contexts/SSEContext'
|
||||
import { Sun, Moon, Monitor, Zap, Radio } from 'lucide-react'
|
||||
|
||||
const SSE_STATUS_COPY: Record<string, { label: string; description: string; color: string }> = {
|
||||
connected: {
|
||||
label: 'Connected',
|
||||
description: 'Real-time updates are active. Agent status, tasks, and progress stream live.',
|
||||
color: 'text-green-500',
|
||||
},
|
||||
connecting: {
|
||||
label: 'Connecting…',
|
||||
description: 'Establishing SSE connection to the backend.',
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
reconnecting: {
|
||||
label: 'Reconnecting…',
|
||||
description: 'Connection lost. Retrying with exponential back-off.',
|
||||
color: 'text-yellow-500',
|
||||
},
|
||||
error: {
|
||||
label: 'Disconnected',
|
||||
description: 'Could not connect to the SSE endpoint. Check that the backend is reachable.',
|
||||
color: 'text-red-500',
|
||||
},
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
||||
const { sseStatus } = useSSEContext()
|
||||
const sseInfo = SSE_STATUS_COPY[sseStatus] ?? SSE_STATUS_COPY.error
|
||||
|
||||
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>
|
||||
|
||||
{/* Real-time connection status */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Radio size={20} className="text-primary" />
|
||||
Real-time Updates
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">SSE Connection</p>
|
||||
<p className="text-sm text-on-surface-variant mt-0.5">{sseInfo.description}</p>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold whitespace-nowrap ${sseInfo.color}`}>
|
||||
{sseInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-muted">
|
||||
Endpoint: <code className="bg-surface-light px-1.5 py-0.5 rounded text-on-surface-variant">/api/events</code>
|
||||
· Events: agent.status, agent.task, agent.progress, fleet.update
|
||||
</p>
|
||||
<p className="text-xs text-on-surface-muted">
|
||||
Polling is disabled. All status updates are pushed from the server over a persistent SSE connection.
|
||||
The client reconnects automatically with exponential back-off on drop.
|
||||
</p>
|
||||
</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
|
||||
55
frontend/src/services/sse.ts
Normal file
55
frontend/src/services/sse.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* SSE event payload types matching the Go backend (internal/handler/sse.go).
|
||||
*
|
||||
* Event format on the wire:
|
||||
* event: <eventType>
|
||||
* data: <json>
|
||||
*/
|
||||
|
||||
import type { AgentStatus } from '../types'
|
||||
|
||||
/** agent.status — agent came online, went offline, changed state */
|
||||
export interface AgentStatusEvent {
|
||||
agentId: string
|
||||
status: AgentStatus
|
||||
/** Optional human-readable reason (e.g. error message) */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** agent.task — a task was assigned to or completed by an agent */
|
||||
export interface AgentTaskEvent {
|
||||
agentId: string
|
||||
taskId: string
|
||||
title: string
|
||||
action: 'assigned' | 'completed' | 'failed'
|
||||
}
|
||||
|
||||
/** agent.progress — incremental progress update for a running task */
|
||||
export interface AgentProgressEvent {
|
||||
agentId: string
|
||||
taskId: string
|
||||
progress: number
|
||||
/** Optional description of what is currently happening */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* fleet.update — bulk refresh of all agents (e.g. after a deployment).
|
||||
* The backend may send partial or complete agent state.
|
||||
*/
|
||||
export interface FleetUpdateEvent {
|
||||
/** ISO timestamp of when the snapshot was taken */
|
||||
timestamp: string
|
||||
/** Number of agents in the fleet */
|
||||
agentCount: number
|
||||
}
|
||||
|
||||
/** Union of all SSE data payloads keyed by event type. */
|
||||
export type SSEPayloadMap = {
|
||||
'agent.status': AgentStatusEvent
|
||||
'agent.task': AgentTaskEvent
|
||||
'agent.progress': AgentProgressEvent
|
||||
'fleet.update': FleetUpdateEvent
|
||||
connected: { clientCount: number }
|
||||
message: unknown
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/* 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/spec",
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
21
frontend/vite.config.ts
Normal file
21
frontend/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
35
go-backend/.dockerignore
Normal file
35
go-backend/.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# Ignore local build artifacts and version-control files
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE / editor
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Dependency cache (already fetched in Dockerfile)
|
||||
vendor/
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# CI / CD
|
||||
.github/
|
||||
.gitea/
|
||||
35
go-backend/Dockerfile
Normal file
35
go-backend/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git ca-certificates
|
||||
|
||||
# Copy dependency files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the binary
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /bin/server ./cmd/server
|
||||
|
||||
# ── Final stage ─────────────────────────────────────────────────────────
|
||||
FROM alpine:latest
|
||||
WORKDIR /app
|
||||
|
||||
# Install ca-certificates for HTTPS outbound calls
|
||||
RUN apk --no-cache add ca-certificates
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /bin/server /app/server
|
||||
|
||||
# Expose the default port (overridden by PORT env var)
|
||||
EXPOSE 8080
|
||||
|
||||
# Run as non-root
|
||||
RUN adduser -D -s /bin/sh appuser
|
||||
USER appuser
|
||||
|
||||
ENTRYPOINT ["/app/server"]
|
||||
125
go-backend/cmd/server/main.go
Normal file
125
go-backend/cmd/server/main.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Command server starts the Control Center Go backend API server.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/gateway"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// ── Configuration ──────────────────────────────────────────────────────
|
||||
cfg := config.Load()
|
||||
|
||||
// ── Logging ────────────────────────────────────────────────────────────
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: parseLogLevel(cfg.LogLevel),
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// ── Database ───────────────────────────────────────────────────────────
|
||||
pool, err := db.New(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
slog.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// ── Repositories (PostgreSQL-backed) ───────────────────────────────────
|
||||
agentRepo := repository.NewAgentRepository(pool.Pool)
|
||||
sessionRepo := repository.NewSessionRepository(pool.Pool)
|
||||
taskRepo := repository.NewTaskRepository(pool.Pool)
|
||||
projectRepo := repository.NewProjectRepository(pool.Pool)
|
||||
|
||||
// ── Seed demo agents on first boot ─────────────────────────────────────
|
||||
if err := gateway.SeedDemoAgents(context.Background(), agentRepo); err != nil {
|
||||
slog.Error("seed demo agents failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// ── SSE Broker ─────────────────────────────────────────────────────────
|
||||
broker := handler.NewBroker()
|
||||
|
||||
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||
h := handler.NewHandler(agentRepo, sessionRepo, taskRepo, projectRepo)
|
||||
|
||||
// ── Router ─────────────────────────────────────────────────────────────
|
||||
r := router.New(&router.Dependencies{
|
||||
Handler: h,
|
||||
Pool: pool,
|
||||
CORSOrigin: cfg.CORSOrigin,
|
||||
Broker: broker,
|
||||
})
|
||||
|
||||
// ── Gateway client (polls OpenClaw for agent states) ───────────────────
|
||||
gwClient := gateway.NewClient(gateway.Config{
|
||||
URL: cfg.GatewayURL,
|
||||
PollInterval: cfg.GatewayPollInterval,
|
||||
}, agentRepo, broker)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
go gwClient.Start(ctx)
|
||||
|
||||
// ── Server ─────────────────────────────────────────────────────────────
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: r,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
slog.Info("server starting", "port", cfg.Port, "env", cfg.Environment)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-quit
|
||||
slog.Info("shutting down server...")
|
||||
|
||||
cancel() // stop gateway polling
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("server forced to shutdown", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("server exited cleanly")
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch level {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
26
go-backend/go.mod
Normal file
26
go-backend/go.mod
Normal file
@@ -0,0 +1,26 @@
|
||||
module code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.0
|
||||
github.com/go-chi/cors v1.2.1
|
||||
github.com/go-playground/validator/v10 v10.24.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
golang.org/x/crypto v0.32.0 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
50
go-backend/go.sum
Normal file
50
go-backend/go.sum
Normal file
@@ -0,0 +1,50 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
|
||||
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
|
||||
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
59
go-backend/internal/config/config.go
Normal file
59
go-backend/internal/config/config.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// Package config provides application configuration loaded from environment
|
||||
// variables with sensible defaults for local development.
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds all application configuration.
|
||||
type Config struct {
|
||||
Port int
|
||||
DatabaseURL string
|
||||
CORSOrigin string
|
||||
LogLevel string
|
||||
Environment string
|
||||
GatewayURL string
|
||||
GatewayPollInterval time.Duration
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables, applying defaults where
|
||||
// values are not set. All secrets come from the environment — nothing is hardcoded.
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
Port: getEnvInt("PORT", 8080),
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgres://controlcenter:controlcenter@localhost:5432/controlcenter?sslmode=disable"),
|
||||
CORSOrigin: getEnv("CORS_ORIGIN", "*"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
GatewayURL: getEnv("GATEWAY_URL", "http://localhost:18789/api/agents"),
|
||||
GatewayPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if i, err := strconv.Atoi(v); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, fallback time.Duration) time.Duration {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if d, err := time.ParseDuration(v); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
62
go-backend/internal/db/db.go
Normal file
62
go-backend/internal/db/db.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Package db provides PostgreSQL connection management using pgx.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Pool wraps a pgx connection pool with lifecycle helpers.
|
||||
type Pool struct {
|
||||
*pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a connection pool from a PostgreSQL DSN.
|
||||
func New(dsn string) (*Pool, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse pgx config: %w", err)
|
||||
}
|
||||
|
||||
// Sensible defaults
|
||||
cfg.MaxConns = 20
|
||||
cfg.MinConns = 2
|
||||
cfg.MaxConnLifetime = 30 * time.Minute
|
||||
cfg.MaxConnIdleTime = 10 * time.Minute
|
||||
cfg.HealthCheckPeriod = 5 * time.Second
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create pgx pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("database connected", "pool", cfg.ConnConfig.Database)
|
||||
return &Pool{Pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close shuts down the pool gracefully.
|
||||
func (p *Pool) Close() {
|
||||
if p.Pool != nil {
|
||||
p.Pool.Close()
|
||||
slog.Info("database pool closed")
|
||||
}
|
||||
}
|
||||
|
||||
// Health returns nil if the database is reachable.
|
||||
func (p *Pool) Health(ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
return p.Ping(ctx)
|
||||
}
|
||||
198
go-backend/internal/gateway/client.go
Normal file
198
go-backend/internal/gateway/client.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// Package gateway provides an OpenClaw gateway integration client that
|
||||
// polls agent states, persists them via the repository layer, and broadcasts
|
||||
// changes through the SSE broker for real-time frontend updates.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||
)
|
||||
|
||||
// Client polls the OpenClaw gateway for agent status and keeps the database
|
||||
// and SSE broker in sync.
|
||||
type Client struct {
|
||||
url string
|
||||
pollInterval time.Duration
|
||||
httpClient *http.Client
|
||||
agents repository.AgentRepo
|
||||
broker *handler.Broker
|
||||
}
|
||||
|
||||
// Config holds gateway client configuration, typically loaded from environment.
|
||||
type Config struct {
|
||||
URL string
|
||||
PollInterval time.Duration
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible defaults for local development.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
URL: "http://localhost:18789/api/agents",
|
||||
PollInterval: 5 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient returns a gateway client wired to the given repository and broker.
|
||||
func NewClient(cfg Config, agents repository.AgentRepo, broker *handler.Broker) *Client {
|
||||
return &Client{
|
||||
url: cfg.URL,
|
||||
pollInterval: cfg.PollInterval,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
agents: agents,
|
||||
broker: broker,
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins the polling loop. It runs until ctx is cancelled.
|
||||
func (c *Client) Start(ctx context.Context) {
|
||||
slog.Info("gateway client starting",
|
||||
"url", c.url,
|
||||
"pollInterval", c.pollInterval.String())
|
||||
|
||||
ticker := time.NewTicker(c.pollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("gateway client stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.poll(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// poll fetches agent states from the gateway and syncs to the database.
|
||||
func (c *Client) poll(ctx context.Context) {
|
||||
resp, err := c.httpClient.Get(c.url)
|
||||
if err != nil {
|
||||
slog.Warn("gateway poll failed", "error", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Warn("gateway returned non-200", "status", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var agents []models.AgentCardData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&agents); err != nil {
|
||||
slog.Warn("gateway response parse failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ga := range agents {
|
||||
// Check if agent already exists; if so, update; otherwise create.
|
||||
existing, err := c.agents.Get(ctx, ga.ID)
|
||||
if err != nil {
|
||||
// Not found — create it
|
||||
if err := c.agents.Create(ctx, ga); err != nil {
|
||||
slog.Warn("gateway agent create failed", "id", ga.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("gateway agent created", "id", ga.ID, "status", ga.Status)
|
||||
c.broker.Broadcast("agent.status", ga)
|
||||
continue
|
||||
}
|
||||
|
||||
// If status changed, update and broadcast
|
||||
if existing.Status != ga.Status {
|
||||
updated, err := c.agents.Update(ctx, ga.ID, models.UpdateAgentRequest{
|
||||
Status: &ga.Status,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("gateway agent update failed", "id", ga.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
c.broker.Broadcast("agent.status", updated)
|
||||
slog.Debug("agent status changed",
|
||||
"id", ga.ID,
|
||||
"from", existing.Status,
|
||||
"to", ga.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SeedDemoAgents inserts the five known demo agents if the agents table is
|
||||
// empty. Call this once on application startup after migrations have run.
|
||||
func SeedDemoAgents(ctx context.Context, agents repository.AgentRepo) error {
|
||||
count, err := agents.Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count agents for seeding: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil // already seeded
|
||||
}
|
||||
|
||||
slog.Info("seeding demo agents")
|
||||
demoAgents := []models.AgentCardData{
|
||||
{
|
||||
ID: "otto",
|
||||
DisplayName: "Otto",
|
||||
Role: "Orchestrator",
|
||||
Status: models.AgentStatusActive,
|
||||
CurrentTask: strPtr("Orchestrating tasks"),
|
||||
SessionKey: "otto-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "rex",
|
||||
DisplayName: "Rex",
|
||||
Role: "Frontend Dev",
|
||||
Status: models.AgentStatusIdle,
|
||||
SessionKey: "rex-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "dex",
|
||||
DisplayName: "Dex",
|
||||
Role: "Backend Dev",
|
||||
Status: models.AgentStatusThinking,
|
||||
CurrentTask: strPtr("Designing API contracts"),
|
||||
SessionKey: "dex-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "hex",
|
||||
DisplayName: "Hex",
|
||||
Role: "Database Specialist",
|
||||
Status: models.AgentStatusActive,
|
||||
CurrentTask: strPtr("Reviewing schema migrations"),
|
||||
SessionKey: "hex-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
},
|
||||
{
|
||||
ID: "pip",
|
||||
DisplayName: "Pip",
|
||||
Role: "Edge Device Dev",
|
||||
Status: models.AgentStatusIdle,
|
||||
SessionKey: "pip-session",
|
||||
Channel: "discord",
|
||||
LastActivity: time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339),
|
||||
},
|
||||
}
|
||||
|
||||
for _, a := range demoAgents {
|
||||
if err := agents.Create(ctx, a); err != nil {
|
||||
return fmt.Errorf("seed agent %s: %w", a.ID, err)
|
||||
}
|
||||
}
|
||||
slog.Info("demo agents seeded", "count", len(demoAgents))
|
||||
return nil
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
162
go-backend/internal/handler/agent.go
Normal file
162
go-backend/internal/handler/agent.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Package handler contains HTTP handlers for the Control Center API.
|
||||
// Each handler is a method on a Handler struct that receives its
|
||||
// dependencies through dependency injection — now wired to PostgreSQL-backed
|
||||
// repository implementations instead of in-memory stores.
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Handler groups all route handlers and their dependencies.
|
||||
type Handler struct {
|
||||
Agents repository.AgentRepo
|
||||
Sessions repository.SessionRepo
|
||||
Tasks repository.TaskRepo
|
||||
Projects repository.ProjectRepo
|
||||
validate *validator.Validate
|
||||
}
|
||||
|
||||
// NewHandler returns a fully wired Handler with repository backends.
|
||||
func NewHandler(
|
||||
ar repository.AgentRepo,
|
||||
sr repository.SessionRepo,
|
||||
tr repository.TaskRepo,
|
||||
pr repository.ProjectRepo,
|
||||
) *Handler {
|
||||
v := validator.New()
|
||||
v.RegisterValidation("agentStatus", validateAgentStatus)
|
||||
return &Handler{
|
||||
Agents: ar,
|
||||
Sessions: sr,
|
||||
Tasks: tr,
|
||||
Projects: pr,
|
||||
validate: v,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Agent Handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
// ListAgents handles GET /api/agents.
|
||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||
statusFilter := models.AgentStatus(r.URL.Query().Get("status"))
|
||||
allAgents, err := h.Agents.List(r.Context(), statusFilter)
|
||||
if err != nil {
|
||||
slog.Error("list agents failed", "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list agents"})
|
||||
return
|
||||
}
|
||||
|
||||
page, pageSize := parsePagination(r)
|
||||
start, end := paginateSlice(len(allAgents), page, pageSize)
|
||||
|
||||
totalCount, _ := h.Agents.Count(r.Context())
|
||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||
Data: allAgents[start:end],
|
||||
TotalCount: totalCount,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
HasMore: end < len(allAgents),
|
||||
})
|
||||
}
|
||||
|
||||
// GetAgent handles GET /api/agents/{id}.
|
||||
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
agent, err := h.Agents.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agent)
|
||||
}
|
||||
|
||||
// CreateAgent handles POST /api/agents.
|
||||
func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.CreateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, models.ErrorResponse{
|
||||
Error: "validation failed",
|
||||
Details: validationErrors(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
agent := models.AgentCardData{
|
||||
ID: req.ID,
|
||||
DisplayName: req.DisplayName,
|
||||
Role: req.Role,
|
||||
Status: req.Status,
|
||||
CurrentTask: req.CurrentTask,
|
||||
SessionKey: req.SessionKey,
|
||||
Channel: req.Channel,
|
||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if err := h.Agents.Create(r.Context(), agent); err != nil {
|
||||
writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, agent)
|
||||
}
|
||||
|
||||
// UpdateAgent handles PUT /api/agents/{id}.
|
||||
func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
|
||||
var req models.UpdateAgentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, models.ErrorResponse{Error: "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validate.Struct(req); err != nil {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, models.ErrorResponse{
|
||||
Error: "validation failed",
|
||||
Details: validationErrors(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
agent, err := h.Agents.Update(r.Context(), id, req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agent)
|
||||
}
|
||||
|
||||
// DeleteAgent handles DELETE /api/agents/{id}.
|
||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.Agents.Delete(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// AgentHistory handles GET /api/agents/{id}/history.
|
||||
func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, err := h.Agents.Get(r.Context(), id); err != nil {
|
||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// History is not currently persisted in PostgreSQL — return stub.
|
||||
writeJSON(w, http.StatusOK, []models.AgentStatusHistoryEntry{})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user