Compare commits
5 Commits
agent/rex/
...
agent/rex/
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b8cb8210c | |||
| 4a2e660a4a | |||
| 07d40d729f | |||
| 437a519c36 | |||
| c906cd46ad |
106
backend/internal/models/models.go
Normal file
106
backend/internal/models/models.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package models defines the database entities for the Control Center Go backend.
|
||||
// Structs map 1:1 to the PostgreSQL schema defined in backend/migrations/.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// AgentStatus represents the possible lifecycle states of an agent.
|
||||
type AgentStatus string
|
||||
|
||||
const (
|
||||
AgentStatusActive AgentStatus = "active"
|
||||
AgentStatusIdle AgentStatus = "idle"
|
||||
AgentStatusThinking AgentStatus = "thinking"
|
||||
AgentStatusError AgentStatus = "error"
|
||||
AgentStatusOffline AgentStatus = "offline"
|
||||
)
|
||||
|
||||
// Agent represents a registered agent and its current state.
|
||||
type Agent struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Status AgentStatus `db:"status" json:"status"`
|
||||
Task *string `db:"task" json:"task,omitempty"`
|
||||
Progress int32 `db:"progress" json:"progress"`
|
||||
SessionKey *string `db:"session_key" json:"session_key,omitempty"`
|
||||
Channel *string `db:"channel" json:"channel,omitempty"`
|
||||
LastActivity time.Time `db:"last_activity" json:"last_activity"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// SessionStatus represents the possible states of an agent session.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusRunning SessionStatus = "running"
|
||||
SessionStatusCompleted SessionStatus = "completed"
|
||||
SessionStatusCrashed SessionStatus = "crashed"
|
||||
SessionStatusTerminated SessionStatus = "terminated"
|
||||
)
|
||||
|
||||
// Session tracks an agent session over time.
|
||||
type Session struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
EndedAt *time.Time `db:"ended_at" json:"ended_at,omitempty"`
|
||||
Status SessionStatus `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
// TaskLogStatus represents the possible states of a task log entry.
|
||||
type TaskLogStatus string
|
||||
|
||||
const (
|
||||
TaskLogStatusPending TaskLogStatus = "pending"
|
||||
TaskLogStatusRunning TaskLogStatus = "running"
|
||||
TaskLogStatusCompleted TaskLogStatus = "completed"
|
||||
TaskLogStatusFailed TaskLogStatus = "failed"
|
||||
TaskLogStatusCancelled TaskLogStatus = "cancelled"
|
||||
)
|
||||
|
||||
// TaskLog records a historical task assigned to an agent.
|
||||
type TaskLog struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||
Task string `db:"task" json:"task"`
|
||||
Status TaskLogStatus `db:"status" json:"status"`
|
||||
StartedAt time.Time `db:"started_at" json:"started_at"`
|
||||
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
|
||||
ErrorMessage *string `db:"error_message" json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectStatus represents the possible states of a project.
|
||||
type ProjectStatus string
|
||||
|
||||
const (
|
||||
ProjectStatusPlanned ProjectStatus = "planned"
|
||||
ProjectStatusInProgress ProjectStatus = "in_progress"
|
||||
ProjectStatusCompleted ProjectStatus = "completed"
|
||||
ProjectStatusPaused ProjectStatus = "paused"
|
||||
ProjectStatusCancelled ProjectStatus = "cancelled"
|
||||
)
|
||||
|
||||
// Project represents a project managed by the Control Center.
|
||||
type Project struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
Status ProjectStatus `db:"status" json:"status"`
|
||||
AgentID *pgtype.UUID `db:"agent_id" json:"agent_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// AgentEvent represents an event in the agent lifecycle or telemetry stream.
|
||||
type AgentEvent struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
AgentID pgtype.UUID `db:"agent_id" json:"agent_id"`
|
||||
EventType string `db:"event_type" json:"event_type"`
|
||||
Payload *map[string]interface{} `db:"payload" json:"payload,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
}
|
||||
9
backend/migrations/001_initial_schema.down.sql
Normal file
9
backend/migrations/001_initial_schema.down.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Migration: 001_initial_schema (down)
|
||||
-- Description: Reverts the core Control Center database schema.
|
||||
|
||||
-- Drop in reverse dependency order to avoid FK conflicts
|
||||
DROP TABLE IF EXISTS agent_events;
|
||||
DROP TABLE IF EXISTS task_logs;
|
||||
DROP TABLE IF EXISTS sessions;
|
||||
DROP TABLE IF EXISTS projects;
|
||||
DROP TABLE IF EXISTS agents;
|
||||
97
backend/migrations/001_initial_schema.up.sql
Normal file
97
backend/migrations/001_initial_schema.up.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- Migration: 001_initial_schema
|
||||
-- Description: Creates the core Control Center database schema.
|
||||
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================
|
||||
-- Table: agents
|
||||
-- ============================================
|
||||
CREATE TABLE agents (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'idle'
|
||||
CHECK (status IN ('active', 'idle', 'thinking', 'error', 'offline')),
|
||||
task TEXT,
|
||||
progress INT NOT NULL DEFAULT 0
|
||||
CHECK (progress >= 0 AND progress <= 100),
|
||||
session_key TEXT,
|
||||
channel TEXT,
|
||||
last_activity TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE agents IS 'Registered agents and their current state';
|
||||
COMMENT ON COLUMN agents.status IS 'Agent lifecycle status: active, idle, thinking, error, offline';
|
||||
|
||||
-- ============================================
|
||||
-- Table: sessions
|
||||
-- ============================================
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agent_id UUID NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'running'
|
||||
CHECK (status IN ('running', 'completed', 'crashed', 'terminated')),
|
||||
CONSTRAINT fk_sessions_agent
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
COMMENT ON TABLE sessions IS 'Agent session history';
|
||||
|
||||
-- ============================================
|
||||
-- Table: task_logs
|
||||
-- ============================================
|
||||
CREATE TABLE task_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agent_id UUID NOT NULL,
|
||||
task TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
CONSTRAINT fk_task_logs_agent
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
COMMENT ON TABLE task_logs IS 'Historical record of tasks assigned to agents';
|
||||
|
||||
-- ============================================
|
||||
-- Table: projects
|
||||
-- ============================================
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'planned'
|
||||
CHECK (status IN ('planned', 'in_progress', 'completed', 'paused', 'cancelled')),
|
||||
agent_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_projects_agent
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
ON DELETE SET NULL
|
||||
);
|
||||
|
||||
COMMENT ON TABLE projects IS 'Projects managed by the Control Center';
|
||||
|
||||
-- ============================================
|
||||
-- Table: agent_events
|
||||
-- ============================================
|
||||
CREATE TABLE agent_events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
agent_id UUID NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT fk_agent_events_agent
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
COMMENT ON TABLE agent_events IS 'Event stream for agent lifecycle and telemetry';
|
||||
20
backend/migrations/002_add_indexes.down.sql
Normal file
20
backend/migrations/002_add_indexes.down.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Migration: 002_add_indexes (down)
|
||||
-- Description: Remove all indexes added in 002_add_indexes.
|
||||
|
||||
DROP INDEX IF EXISTS idx_agents_status;
|
||||
DROP INDEX IF EXISTS idx_agents_last_activity;
|
||||
DROP INDEX IF EXISTS idx_agents_created_at;
|
||||
|
||||
DROP INDEX IF EXISTS idx_sessions_agent_id;
|
||||
DROP INDEX IF EXISTS idx_sessions_status;
|
||||
DROP INDEX IF EXISTS idx_sessions_started_at;
|
||||
|
||||
DROP INDEX IF EXISTS idx_task_logs_agent_started;
|
||||
DROP INDEX IF EXISTS idx_task_logs_status;
|
||||
|
||||
DROP INDEX IF EXISTS idx_agent_events_agent_created;
|
||||
DROP INDEX IF EXISTS idx_agent_events_event_type;
|
||||
|
||||
DROP INDEX IF EXISTS idx_projects_status;
|
||||
DROP INDEX IF EXISTS idx_projects_agent_id;
|
||||
DROP INDEX IF EXISTS idx_projects_created;
|
||||
25
backend/migrations/002_add_indexes.up.sql
Normal file
25
backend/migrations/002_add_indexes.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Migration: 002_add_indexes
|
||||
-- Description: Add performance indexes for common query patterns.
|
||||
|
||||
-- agents: status filtering, activity ordering
|
||||
CREATE INDEX idx_agents_status ON agents(status);
|
||||
CREATE INDEX idx_agents_last_activity ON agents(last_activity DESC);
|
||||
CREATE INDEX idx_agents_created_at ON agents(created_at DESC);
|
||||
|
||||
-- sessions: agent session lookups, active session checks
|
||||
CREATE INDEX idx_sessions_agent_id ON sessions(agent_id);
|
||||
CREATE INDEX idx_sessions_status ON sessions(status);
|
||||
CREATE INDEX idx_sessions_started_at ON sessions(started_at DESC);
|
||||
|
||||
-- task_logs: agent task history, chronological ordering
|
||||
CREATE INDEX idx_task_logs_agent_started ON task_logs(agent_id, started_at DESC);
|
||||
CREATE INDEX idx_task_logs_status ON task_logs(status);
|
||||
|
||||
-- agent_events: event stream queries
|
||||
CREATE INDEX idx_agent_events_agent_created ON agent_events(agent_id, created_at DESC);
|
||||
CREATE INDEX idx_agent_events_event_type ON agent_events(event_type);
|
||||
|
||||
-- projects: status filtering, agent assignment
|
||||
CREATE INDEX idx_projects_status ON projects(status);
|
||||
CREATE INDEX idx_projects_agent_id ON projects(agent_id);
|
||||
CREATE INDEX idx_projects_created ON projects(created_at DESC);
|
||||
29
frontend/src/hooks/useLocalStorage.ts
Normal file
29
frontend/src/hooks/useLocalStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item !== null ? (JSON.parse(item) as T) : initialValue
|
||||
} catch {
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(storedValue))
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
}, [key, storedValue])
|
||||
|
||||
const setValue = useCallback((value: T | ((prev: T) => T)) => {
|
||||
setStoredValue((prev) => {
|
||||
const next = value instanceof Function ? value(prev) : value
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return [storedValue, setValue]
|
||||
}
|
||||
50
frontend/src/hooks/useTheme.tsx
Normal file
50
frontend/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
toggleTheme: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === 'undefined') return 'dark'
|
||||
const stored = localStorage.getItem('cc-theme')
|
||||
if (stored === 'light' || stored === 'dark') return stored
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>(getInitialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light')
|
||||
} else {
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('cc-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -18,6 +18,17 @@
|
||||
--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 {
|
||||
@@ -27,3 +38,39 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { ThemeProvider } from './hooks/useTheme'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
@@ -19,11 +20,13 @@ const queryClient = new QueryClient({
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -1,91 +1,198 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { listAgents } from '../services/api'
|
||||
import { Activity, AlertTriangle } from 'lucide-react'
|
||||
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 { data, isLoading, error } = useQuery({
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
return <HubSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-danger">
|
||||
<AlertTriangle size={24} className="mr-2" />
|
||||
Failed to load agents
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
const agents = data?.data ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Command Hub</h1>
|
||||
<p className="text-on-surface-variant">Agent fleet overview</p>
|
||||
{/* 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">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold">{agent.displayName}</h3>
|
||||
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
||||
{[...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>
|
||||
<StatusDot status={agent.status} />
|
||||
</div>
|
||||
|
||||
{agent.currentTask && (
|
||||
<div className="text-sm text-on-surface-variant mb-2">
|
||||
{agent.currentTask}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agent.taskProgress !== undefined && (
|
||||
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${agent.taskProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
||||
<Activity size={12} />
|
||||
{agent.channel} · {agent.lastActivity}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
active: 'bg-status-active',
|
||||
idle: 'bg-status-idle',
|
||||
thinking: 'bg-status-thinking',
|
||||
error: 'bg-status-error',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${colorMap[status] ?? 'bg-status-offline'}`} />
|
||||
<span className="text-xs capitalize text-on-surface-variant">{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +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>
|
||||
<h1 className="text-2xl font-bold mb-4">Logs</h1>
|
||||
<p className="text-on-surface-variant">Activity logs will appear here.</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +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>
|
||||
<h1 className="text-2xl font-bold mb-4">Projects</h1>
|
||||
<p className="text-on-surface-variant">Tracked projects will appear here.</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +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>
|
||||
<h1 className="text-2xl font-bold mb-4">Sessions</h1>
|
||||
<p className="text-on-surface-variant">Active sessions will appear here.</p>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,126 @@
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { useLocalStorage } from '../hooks/useLocalStorage'
|
||||
import { Sun, Moon, Monitor, Zap, Clock } from 'lucide-react'
|
||||
|
||||
const REFRESH_PRESETS = [
|
||||
{ label: '5s', value: 5_000 },
|
||||
{ label: '10s', value: 10_000 },
|
||||
{ label: '30s', value: 30_000 },
|
||||
{ label: '60s', value: 60_000 },
|
||||
]
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const [gatewayUrl, setGatewayUrl] = useLocalStorage('cc-gateway-url', '')
|
||||
const [refreshInterval, setRefreshInterval] = useLocalStorage('cc-refresh-interval', 30_000)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
||||
<p className="text-on-surface-variant">System settings will appear here.</p>
|
||||
<div className="space-y-8 max-w-2xl">
|
||||
<header>
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-on-surface-variant">Application preferences</p>
|
||||
</header>
|
||||
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Monitor size={20} className="text-primary" />
|
||||
Appearance
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">Dark Mode</p>
|
||||
<p className="text-sm text-on-surface-variant">Toggle between dark and light themes</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`relative w-14 h-8 rounded-full transition-colors duration-200 ${
|
||||
isDark ? 'bg-primary' : 'bg-surface-lighter'
|
||||
}`}
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-6 w-6 rounded-full bg-white transition-transform duration-200 flex items-center justify-center ${
|
||||
isDark ? 'translate-x-7' : 'translate-x-1'
|
||||
}`}
|
||||
>
|
||||
{isDark ? <Moon size={14} className="text-surface-darkest" /> : <Sun size={14} className="text-yellow-500" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Zap size={20} className="text-primary" />
|
||||
Connection
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||
<div>
|
||||
<label htmlFor="gateway-url" className="block text-sm font-medium mb-1">
|
||||
Gateway URL
|
||||
</label>
|
||||
<input
|
||||
id="gateway-url"
|
||||
type="url"
|
||||
value={gatewayUrl}
|
||||
onChange={(e) => setGatewayUrl(e.target.value)}
|
||||
placeholder="http://localhost:8080"
|
||||
className="w-full px-3 py-2 rounded-lg border border-surface-light bg-surface-darkest text-on-surface placeholder:text-on-surface-muted focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-on-surface-muted mt-1">
|
||||
The backend Gateway address for API requests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Refresh */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock size={20} className="text-primary" />
|
||||
Auto Refresh
|
||||
</h2>
|
||||
|
||||
<div className="p-5 rounded-xl border border-surface-light bg-surface-dark space-y-3">
|
||||
<p className="text-sm text-on-surface-variant">Data refresh interval for agent status and logs</p>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="3"
|
||||
step="1"
|
||||
value={REFRESH_PRESETS.findIndex((p) => p.value === refreshInterval)}
|
||||
onChange={(e) => {
|
||||
const idx = parseInt(e.target.value)
|
||||
setRefreshInterval(REFRESH_PRESETS[idx].value)
|
||||
}}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-on-surface-muted">
|
||||
{REFRESH_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.label}
|
||||
onClick={() => setRefreshInterval(p.value)}
|
||||
className={`px-3 py-1 rounded-lg transition-colors ${
|
||||
refreshInterval === p.value
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-surface-light'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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"]
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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/handler"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
||||
@@ -27,6 +28,16 @@ func main() {
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// ── Database (optional until CUB-120 schema is ready) ──────────────────
|
||||
var pool *db.Pool
|
||||
if cfg.DatabaseURL != "" {
|
||||
var err error
|
||||
pool, err = db.New(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
slog.Warn("database connection failed; running without DB", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stores (in-memory for now; PostgreSQL after CUB-120) ────────────────
|
||||
agentStore := store.NewAgentStore()
|
||||
sessionStore := store.NewSessionStore()
|
||||
@@ -37,7 +48,11 @@ func main() {
|
||||
h := handler.NewHandler(agentStore, sessionStore, taskStore, projectStore)
|
||||
|
||||
// ── Router ─────────────────────────────────────────────────────────────
|
||||
r := router.New(h)
|
||||
r := router.New(&router.Dependencies{
|
||||
Handler: h,
|
||||
DB: pool,
|
||||
CORSOrigin: cfg.CORSOrigin,
|
||||
})
|
||||
|
||||
// ── Server ─────────────────────────────────────────────────────────────
|
||||
srv := &http.Server{
|
||||
@@ -71,6 +86,10 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if pool != nil {
|
||||
pool.Close()
|
||||
}
|
||||
|
||||
slog.Info("server exited cleanly")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,20 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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=
|
||||
@@ -16,19 +17,34 @@ github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE
|
||||
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=
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -6,14 +6,22 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/db"
|
||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/handler"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
// Dependencies carries the handler and database pool into the router.
|
||||
type Dependencies struct {
|
||||
Handler *handler.Handler
|
||||
DB *db.Pool
|
||||
CORSOrigin string
|
||||
}
|
||||
|
||||
// New creates a fully-configured chi router with all API routes mounted.
|
||||
func New(h *handler.Handler) *chi.Mux {
|
||||
func New(deps *Dependencies) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// ── Global middleware ──────────────────────────────────────────────────
|
||||
@@ -23,9 +31,13 @@ func New(h *handler.Handler) *chi.Mux {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
|
||||
// ── CORS — permissive for development ──────────────────────────────────
|
||||
// ── CORS ───────────────────────────────────────────────────────────────
|
||||
corsOrigin := deps.CORSOrigin
|
||||
if corsOrigin == "" {
|
||||
corsOrigin = "*"
|
||||
}
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedOrigins: []string{corsOrigin},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
ExposedHeaders: []string{"Link", "X-Total-Count"},
|
||||
@@ -33,32 +45,39 @@ func New(h *handler.Handler) *chi.Mux {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
// ── Health check ───────────────────────────────────────────────────────
|
||||
// ── Health check (with DB connectivity probe) ──────────────────────────
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
status := "ok"
|
||||
if deps.DB != nil {
|
||||
if err := deps.DB.Health(r.Context()); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
status = "db_unhealthy"
|
||||
}
|
||||
}
|
||||
w.Write([]byte(`{"status":"` + status + `"}`))
|
||||
})
|
||||
|
||||
// ── API v1 routes ──────────────────────────────────────────────────────
|
||||
r.Route("/api", func(api chi.Router) {
|
||||
// Agents CRUD
|
||||
api.Route("/agents", func(agents chi.Router) {
|
||||
agents.Get("/", h.ListAgents) // GET /api/agents
|
||||
agents.Post("/", h.CreateAgent) // POST /api/agents
|
||||
agents.Get("/{id}", h.GetAgent) // GET /api/agents/{id}
|
||||
agents.Put("/{id}", h.UpdateAgent) // PUT /api/agents/{id}
|
||||
agents.Delete("/{id}", h.DeleteAgent) // DELETE /api/agents/{id}
|
||||
agents.Get("/{id}/history", h.AgentHistory) // GET /api/agents/{id}/history
|
||||
agents.Get("/", deps.Handler.ListAgents) // GET /api/agents
|
||||
agents.Post("/", deps.Handler.CreateAgent) // POST /api/agents
|
||||
agents.Get("/{id}", deps.Handler.GetAgent) // GET /api/agents/{id}
|
||||
agents.Put("/{id}", deps.Handler.UpdateAgent) // PUT /api/agents/{id}
|
||||
agents.Delete("/{id}", deps.Handler.DeleteAgent) // DELETE /api/agents/{id}
|
||||
agents.Get("/{id}/history", deps.Handler.AgentHistory) // GET /api/agents/{id}/history
|
||||
})
|
||||
|
||||
// Sessions
|
||||
api.Get("/sessions", h.ListSessions)
|
||||
api.Get("/sessions", deps.Handler.ListSessions)
|
||||
|
||||
// Tasks
|
||||
api.Get("/tasks", h.ListTasks)
|
||||
api.Get("/tasks", deps.Handler.ListTasks)
|
||||
|
||||
// Projects
|
||||
api.Get("/projects", h.ListProjects)
|
||||
api.Get("/projects", deps.Handler.ListProjects)
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
Reference in New Issue
Block a user