Compare commits
1 Commits
e8ced74429
...
agent/rex/
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b8cb8210c |
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-thinking: #A78BFA;
|
||||||
--color-status-error: #F87171;
|
--color-status-error: #F87171;
|
||||||
--color-status-offline: #64748B;
|
--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 {
|
body {
|
||||||
@@ -27,3 +38,39 @@ body {
|
|||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
font-family: 'Inter', 'Roboto', sans-serif;
|
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import ErrorBoundary from './components/ErrorBoundary'
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
|
import { ThemeProvider } from './hooks/useTheme'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
@@ -19,11 +20,13 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,91 +1,198 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { listAgents } from '../services/api'
|
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() {
|
export default function HubPage() {
|
||||||
const { data, isLoading, error } = useQuery({
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading, error, refetch, isRefetching } = useQuery({
|
||||||
queryKey: ['agents'],
|
queryKey: ['agents'],
|
||||||
queryFn: listAgents,
|
queryFn: listAgents,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const agents = data?.data ?? []
|
||||||
|
const stats = statusStats(agents)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <HubSkeleton />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64 text-danger">
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<AlertTriangle size={24} className="mr-2" />
|
<AlertCircle size={48} className="text-danger" />
|
||||||
Failed to load agents
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const agents = data?.data ?? []
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<header>
|
{/* Header */}
|
||||||
|
<header className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Command Hub</h1>
|
<h1 className="text-2xl font-bold">Command Hub</h1>
|
||||||
<p className="text-on-surface-variant">Agent fleet overview</p>
|
<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>
|
</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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<div
|
<div
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
className="p-4 rounded-xl border border-surface-light bg-surface-dark"
|
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-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>
|
<div>
|
||||||
<h3 className="font-semibold">{agent.displayName}</h3>
|
<h3 className="font-semibold text-sm">{agent.displayName}</h3>
|
||||||
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
<p className="text-xs text-on-surface-variant">{agent.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<StatusDot status={agent.status} />
|
</div>
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Current task */}
|
||||||
{agent.currentTask && (
|
{agent.currentTask && (
|
||||||
<div className="text-sm text-on-surface-variant mb-2">
|
<div className="mb-2 text-sm text-on-surface-variant truncate">
|
||||||
{agent.currentTask}
|
{agent.currentTask}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{agent.taskProgress !== undefined && (
|
{/* Progress bar */}
|
||||||
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden">
|
{agent.taskProgress !== undefined && agent.taskProgress > 0 && (
|
||||||
|
<div className="w-full h-2 bg-surface-light rounded-full overflow-hidden mb-2">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary rounded-full transition-all"
|
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||||
style={{ width: `${agent.taskProgress}%` }}
|
style={{ width: `${Math.min(agent.taskProgress, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Footer info */}
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
<div className="mt-3 flex items-center gap-2 text-xs text-on-surface-muted">
|
||||||
<Activity size={12} />
|
<Activity size={12} />
|
||||||
{agent.channel} · {agent.lastActivity}
|
<span>{agent.channel}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{agent.lastActivity}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Logs</h1>
|
<AlertCircle size={48} className="text-danger" />
|
||||||
<p className="text-on-surface-variant">Activity logs will appear here.</p>
|
<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>
|
</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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Projects</h1>
|
<AlertCircle size={48} className="text-danger" />
|
||||||
<p className="text-on-surface-variant">Tracked projects will appear here.</p>
|
<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>
|
</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() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<h1 className="text-2xl font-bold mb-4">Sessions</h1>
|
<AlertCircle size={48} className="text-danger" />
|
||||||
<p className="text-on-surface-variant">Active sessions will appear here.</p>
|
<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>
|
</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() {
|
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 (
|
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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
<p className="font-medium">Dark Mode</p>
|
||||||
<p className="text-on-surface-variant">System settings will appear here.</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ import (
|
|||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/config"
|
"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/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/handler"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/router"
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -29,51 +28,32 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
slog.SetDefault(logger)
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
// ── Database ───────────────────────────────────────────────────────────
|
// ── Database (optional until CUB-120 schema is ready) ──────────────────
|
||||||
pool, err := db.New(cfg.DatabaseURL)
|
var pool *db.Pool
|
||||||
|
if cfg.DatabaseURL != "" {
|
||||||
|
var err error
|
||||||
|
pool, err = db.New(cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("database connection failed", "error", err)
|
slog.Warn("database connection failed; running without DB", "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 ─────────────────────────────────────────────────────────
|
// ── Stores (in-memory for now; PostgreSQL after CUB-120) ────────────────
|
||||||
broker := handler.NewBroker()
|
agentStore := store.NewAgentStore()
|
||||||
|
sessionStore := store.NewSessionStore()
|
||||||
|
taskStore := store.NewTaskStore()
|
||||||
|
projectStore := store.NewProjectStore()
|
||||||
|
|
||||||
// ── HTTP handler ───────────────────────────────────────────────────────
|
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||||
h := handler.NewHandler(agentRepo, sessionRepo, taskRepo, projectRepo)
|
h := handler.NewHandler(agentStore, sessionStore, taskStore, projectStore)
|
||||||
|
|
||||||
// ── Router ─────────────────────────────────────────────────────────────
|
// ── Router ─────────────────────────────────────────────────────────────
|
||||||
r := router.New(&router.Dependencies{
|
r := router.New(&router.Dependencies{
|
||||||
Handler: h,
|
Handler: h,
|
||||||
Pool: pool,
|
DB: pool,
|
||||||
CORSOrigin: cfg.CORSOrigin,
|
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 ─────────────────────────────────────────────────────────────
|
// ── Server ─────────────────────────────────────────────────────────────
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||||
@@ -98,16 +78,18 @@ func main() {
|
|||||||
<-quit
|
<-quit
|
||||||
slog.Info("shutting down server...")
|
slog.Info("shutting down server...")
|
||||||
|
|
||||||
cancel() // stop gateway polling
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
defer shutdownCancel()
|
|
||||||
|
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
||||||
slog.Error("server forced to shutdown", "error", err)
|
slog.Error("server forced to shutdown", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if pool != nil {
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
|
||||||
slog.Info("server exited cleanly")
|
slog.Info("server exited cleanly")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds all application configuration.
|
// Config holds all application configuration.
|
||||||
@@ -15,8 +14,6 @@ type Config struct {
|
|||||||
CORSOrigin string
|
CORSOrigin string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
Environment string
|
Environment string
|
||||||
GatewayURL string
|
|
||||||
GatewayPollInterval time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables, applying defaults where
|
// Load reads configuration from environment variables, applying defaults where
|
||||||
@@ -28,8 +25,6 @@ func Load() *Config {
|
|||||||
CORSOrigin: getEnv("CORS_ORIGIN", "*"),
|
CORSOrigin: getEnv("CORS_ORIGIN", "*"),
|
||||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||||
Environment: getEnv("ENVIRONMENT", "development"),
|
Environment: getEnv("ENVIRONMENT", "development"),
|
||||||
GatewayURL: getEnv("GATEWAY_URL", "http://localhost:18789/api/agents"),
|
|
||||||
GatewayPollInterval: getEnvDuration("GATEWAY_POLL_INTERVAL", 5*time.Second),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +43,3 @@ func getEnvInt(key string, fallback int) int {
|
|||||||
}
|
}
|
||||||
return fallback
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,198 +0,0 @@
|
|||||||
// 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 }
|
|
||||||
@@ -1,44 +1,42 @@
|
|||||||
// Package handler contains HTTP handlers for the Control Center API.
|
// Package handler contains HTTP handlers for the Control Center API.
|
||||||
// Each handler is a method on a Handler struct that receives its
|
// Each handler is a method on a Handler struct that receives its
|
||||||
// dependencies through dependency injection — now wired to PostgreSQL-backed
|
// dependencies (stores) through dependency injection.
|
||||||
// repository implementations instead of in-memory stores.
|
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/repository"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler groups all route handlers and their dependencies.
|
// Handler groups all route handlers and their dependencies.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
Agents repository.AgentRepo
|
AgentStore *store.AgentStore
|
||||||
Sessions repository.SessionRepo
|
SessionStore *store.SessionStore
|
||||||
Tasks repository.TaskRepo
|
TaskStore *store.TaskStore
|
||||||
Projects repository.ProjectRepo
|
ProjectStore *store.ProjectStore
|
||||||
validate *validator.Validate
|
validate *validator.Validate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a fully wired Handler with repository backends.
|
// NewHandler returns a fully wired Handler.
|
||||||
func NewHandler(
|
func NewHandler(
|
||||||
ar repository.AgentRepo,
|
as *store.AgentStore,
|
||||||
sr repository.SessionRepo,
|
ss *store.SessionStore,
|
||||||
tr repository.TaskRepo,
|
ts *store.TaskStore,
|
||||||
pr repository.ProjectRepo,
|
ps *store.ProjectStore,
|
||||||
) *Handler {
|
) *Handler {
|
||||||
v := validator.New()
|
v := validator.New()
|
||||||
v.RegisterValidation("agentStatus", validateAgentStatus)
|
v.RegisterValidation("agentStatus", validateAgentStatus)
|
||||||
return &Handler{
|
return &Handler{
|
||||||
Agents: ar,
|
AgentStore: as,
|
||||||
Sessions: sr,
|
SessionStore: ss,
|
||||||
Tasks: tr,
|
TaskStore: ts,
|
||||||
Projects: pr,
|
ProjectStore: ps,
|
||||||
validate: v,
|
validate: v,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,20 +46,15 @@ func NewHandler(
|
|||||||
// ListAgents handles GET /api/agents.
|
// ListAgents handles GET /api/agents.
|
||||||
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
||||||
statusFilter := models.AgentStatus(r.URL.Query().Get("status"))
|
statusFilter := models.AgentStatus(r.URL.Query().Get("status"))
|
||||||
allAgents, err := h.Agents.List(r.Context(), statusFilter)
|
allAgents := h.AgentStore.List(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)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(allAgents), page, pageSize)
|
start, end := paginateSlice(len(allAgents), page, pageSize)
|
||||||
|
|
||||||
totalCount, _ := h.Agents.Count(r.Context())
|
pageSlice := allAgents[start:end]
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: allAgents[start:end],
|
Data: pageSlice,
|
||||||
TotalCount: totalCount,
|
TotalCount: h.AgentStore.Count(),
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(allAgents),
|
HasMore: end < len(allAgents),
|
||||||
@@ -71,8 +64,8 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
|
|||||||
// GetAgent handles GET /api/agents/{id}.
|
// GetAgent handles GET /api/agents/{id}.
|
||||||
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
agent, err := h.Agents.Get(r.Context(), id)
|
agent, ok := h.AgentStore.Get(id)
|
||||||
if err != nil {
|
if !ok {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -106,7 +99,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
LastActivity: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.Agents.Create(r.Context(), agent); err != nil {
|
if ok := h.AgentStore.Create(agent); !ok {
|
||||||
writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"})
|
writeJSON(w, http.StatusConflict, models.ErrorResponse{Error: "agent with this ID already exists"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -131,8 +124,8 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agent, err := h.Agents.Update(r.Context(), id, req)
|
agent, ok := h.AgentStore.Update(id, req)
|
||||||
if err != nil {
|
if !ok {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -142,7 +135,7 @@ func (h *Handler) UpdateAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
// DeleteAgent handles DELETE /api/agents/{id}.
|
// DeleteAgent handles DELETE /api/agents/{id}.
|
||||||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if err := h.Agents.Delete(r.Context(), id); err != nil {
|
if ok := h.AgentStore.Delete(id); !ok {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -152,11 +145,14 @@ func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
// AgentHistory handles GET /api/agents/{id}/history.
|
// AgentHistory handles GET /api/agents/{id}/history.
|
||||||
func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) AgentHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if _, err := h.Agents.Get(r.Context(), id); err != nil {
|
if _, ok := h.AgentStore.Get(id); !ok {
|
||||||
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
writeJSON(w, http.StatusNotFound, models.ErrorResponse{Error: "agent not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// History is not currently persisted in PostgreSQL — return stub.
|
history := h.AgentStore.History(id)
|
||||||
writeJSON(w, http.StatusOK, []models.AgentStatusHistoryEntry{})
|
if history == nil {
|
||||||
|
history = []models.AgentStatusHistoryEntry{}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, history)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,18 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/store"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testHandler creates a Handler wired to mock repositories for testing.
|
// testHandler creates a Handler wired to fresh in-memory stores for testing.
|
||||||
func testHandler(t *testing.T) *Handler {
|
func testHandler(t *testing.T) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
return NewHandler(
|
return NewHandler(
|
||||||
newMockAgentRepo(),
|
store.NewAgentStore(),
|
||||||
newMockSessionRepo(),
|
store.NewSessionStore(),
|
||||||
newMockTaskRepo(),
|
store.NewTaskStore(),
|
||||||
newMockProjectRepo(),
|
store.NewProjectStore(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ func TestCreateAgent_Success(t *testing.T) {
|
|||||||
|
|
||||||
a := parseAgent(t, w)
|
a := parseAgent(t, w)
|
||||||
if a.ID != "dex" {
|
if a.ID != "dex" {
|
||||||
t.Errorf("expected id=dex, got %s", a.ID)
|
t.Errorf("expected id=dax, got %s", a.ID)
|
||||||
}
|
}
|
||||||
if a.Status != models.AgentStatusIdle {
|
if a.Status != models.AgentStatusIdle {
|
||||||
t.Errorf("expected status=idle, got %s", a.Status)
|
t.Errorf("expected status=idle, got %s", a.Status)
|
||||||
@@ -222,6 +223,7 @@ func TestDeleteAgent(t *testing.T) {
|
|||||||
func TestAgentHistory(t *testing.T) {
|
func TestAgentHistory(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
serveChi(h, "POST", "/api/agents", `{"id":"nano","displayName":"Nano","role":"Firmware","status":"idle","sessionKey":"s1","channel":"discord"}`)
|
||||||
|
serveChi(h, "PUT", "/api/agents/nano", `{"status":"thinking","currentTask":"mqtt payload"}`)
|
||||||
|
|
||||||
w := serveChi(h, "GET", "/api/agents/nano/history", "")
|
w := serveChi(h, "GET", "/api/agents/nano/history", "")
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
@@ -230,9 +232,12 @@ func TestAgentHistory(t *testing.T) {
|
|||||||
|
|
||||||
var entries []models.AgentStatusHistoryEntry
|
var entries []models.AgentStatusHistoryEntry
|
||||||
json.NewDecoder(w.Result().Body).Decode(&entries)
|
json.NewDecoder(w.Result().Body).Decode(&entries)
|
||||||
// History returns empty stub since not yet in PostgreSQL
|
if len(entries) < 2 {
|
||||||
if entries == nil {
|
t.Errorf("expected at least 2 history entries, got %d", len(entries))
|
||||||
t.Error("expected non-nil history slice")
|
}
|
||||||
|
// Newest first — first entry should be "thinking"
|
||||||
|
if entries[0].Status != models.AgentStatusThinking {
|
||||||
|
t.Errorf("expected newest entry status=thinking, got %s", entries[0].Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +249,7 @@ func TestAgentHistory_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Session Tests ─────────────────────────────────────────────────────────═
|
// ─── Session Tests ─────────────────────────────────────────────────────────════
|
||||||
|
|
||||||
func TestListSessions_Empty(t *testing.T) {
|
func TestListSessions_Empty(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
@@ -260,14 +265,14 @@ func TestListSessions_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListSessions_WithData(t *testing.T) {
|
func TestListSessions_WithData(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
h.Sessions.Create(nil, models.Session{
|
h.SessionStore.Create(models.Session{
|
||||||
SessionKey: "sess-1",
|
SessionKey: "sess-1",
|
||||||
AgentID: "dex",
|
AgentID: "dex",
|
||||||
Channel: "discord",
|
Channel: "discord",
|
||||||
Status: "running",
|
Status: "running",
|
||||||
Model: "deepseek-v4",
|
Model: "deepseek-v4",
|
||||||
})
|
})
|
||||||
h.Sessions.Create(nil, models.Session{
|
h.SessionStore.Create(models.Session{
|
||||||
SessionKey: "sess-2",
|
SessionKey: "sess-2",
|
||||||
AgentID: "otto",
|
AgentID: "otto",
|
||||||
Channel: "discord",
|
Channel: "discord",
|
||||||
@@ -294,7 +299,7 @@ func TestListTasks_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListTasks_WithData(t *testing.T) {
|
func TestListTasks_WithData(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
h.Tasks.Create(nil, models.Task{
|
h.TaskStore.Create(models.Task{
|
||||||
AgentID: "dex",
|
AgentID: "dex",
|
||||||
Title: "Implement CRUD API",
|
Title: "Implement CRUD API",
|
||||||
Status: models.TaskStatusRunning,
|
Status: models.TaskStatusRunning,
|
||||||
@@ -319,7 +324,7 @@ func TestListProjects_Empty(t *testing.T) {
|
|||||||
|
|
||||||
func TestListProjects_WithData(t *testing.T) {
|
func TestListProjects_WithData(t *testing.T) {
|
||||||
h := testHandler(t)
|
h := testHandler(t)
|
||||||
h.Projects.Create(nil, models.Project{
|
h.ProjectStore.Create(models.Project{
|
||||||
Name: "Extrudex",
|
Name: "Extrudex",
|
||||||
Description: "Filament inventory system",
|
Description: "Filament inventory system",
|
||||||
Status: models.ProjectStatusActive,
|
Status: models.ProjectStatusActive,
|
||||||
@@ -343,6 +348,7 @@ func TestPagination_PageOutOfRange(t *testing.T) {
|
|||||||
if len(pr.Data.([]any)) != 0 {
|
if len(pr.Data.([]any)) != 0 {
|
||||||
t.Errorf("expected empty page, got %d items", len(pr.Data.([]any)))
|
t.Errorf("expected empty page, got %d items", len(pr.Data.([]any)))
|
||||||
}
|
}
|
||||||
|
// HasMore=false because we're past all data — nothing more to fetch.
|
||||||
if pr.HasMore {
|
if pr.HasMore {
|
||||||
t.Error("expected HasMore=false when page is beyond data")
|
t.Error("expected HasMore=false when page is beyond data")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mockAgentRepo implements repository.AgentRepo in-memory for testing.
|
|
||||||
type mockAgentRepo struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
m map[string]models.AgentCardData
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMockAgentRepo() *mockAgentRepo {
|
|
||||||
return &mockAgentRepo{m: make(map[string]models.AgentCardData)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockAgentRepo) Create(ctx context.Context, a models.AgentCardData) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if _, ok := r.m[a.ID]; ok {
|
|
||||||
return fmt.Errorf("duplicate key: %s", a.ID)
|
|
||||||
}
|
|
||||||
r.m[a.ID] = a
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockAgentRepo) Get(ctx context.Context, id string) (models.AgentCardData, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
a, ok := r.m[id]
|
|
||||||
if !ok {
|
|
||||||
return a, fmt.Errorf("not found: %s", id)
|
|
||||||
}
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockAgentRepo) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
result := make([]models.AgentCardData, 0, len(r.m))
|
|
||||||
for _, a := range r.m {
|
|
||||||
if statusFilter != "" && a.Status != statusFilter {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result = append(result, a)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockAgentRepo) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
a, ok := r.m[id]
|
|
||||||
if !ok {
|
|
||||||
return a, fmt.Errorf("not found: %s", id)
|
|
||||||
}
|
|
||||||
if req.Status != nil {
|
|
||||||
a.Status = *req.Status
|
|
||||||
}
|
|
||||||
if req.CurrentTask != nil {
|
|
||||||
a.CurrentTask = req.CurrentTask
|
|
||||||
}
|
|
||||||
if req.TaskProgress != nil {
|
|
||||||
a.TaskProgress = req.TaskProgress
|
|
||||||
}
|
|
||||||
if req.TaskElapsed != nil {
|
|
||||||
a.TaskElapsed = req.TaskElapsed
|
|
||||||
}
|
|
||||||
if req.Channel != nil {
|
|
||||||
a.Channel = *req.Channel
|
|
||||||
}
|
|
||||||
if req.ErrorMessage != nil {
|
|
||||||
a.ErrorMessage = req.ErrorMessage
|
|
||||||
}
|
|
||||||
a.LastActivity = time.Now().UTC().Format(time.RFC3339)
|
|
||||||
r.m[id] = a
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockAgentRepo) Delete(ctx context.Context, id string) error {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if _, ok := r.m[id]; !ok {
|
|
||||||
return fmt.Errorf("not found: %s", id)
|
|
||||||
}
|
|
||||||
delete(r.m, id)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockAgentRepo) Count(ctx context.Context) (int, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
return len(r.m), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock Session Repo ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type mockSessionRepo struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
m map[string]models.Session
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMockSessionRepo() *mockSessionRepo {
|
|
||||||
return &mockSessionRepo{m: make(map[string]models.Session)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockSessionRepo) Create(ctx context.Context, s models.Session) (models.Session, error) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if s.ID == "" {
|
|
||||||
s.ID = fmt.Sprintf("sess-%d", len(r.m)+1)
|
|
||||||
}
|
|
||||||
if s.StartedAt.IsZero() {
|
|
||||||
s.StartedAt = time.Now().UTC()
|
|
||||||
}
|
|
||||||
if s.LastActivityAt.IsZero() {
|
|
||||||
s.LastActivityAt = s.StartedAt
|
|
||||||
}
|
|
||||||
r.m[s.ID] = s
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockSessionRepo) ListActive(ctx context.Context) ([]models.Session, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
result := make([]models.Session, 0)
|
|
||||||
for _, s := range r.m {
|
|
||||||
if s.Status == "running" || s.Status == "streaming" {
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockSessionRepo) Count(ctx context.Context) (int, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
return len(r.m), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock Task Repo ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type mockTaskRepo struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
m map[string]models.Task
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMockTaskRepo() *mockTaskRepo {
|
|
||||||
return &mockTaskRepo{m: make(map[string]models.Task)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockTaskRepo) Create(ctx context.Context, t models.Task) (models.Task, error) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if t.ID == "" {
|
|
||||||
t.ID = fmt.Sprintf("task-%d", len(r.m)+1)
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if t.CreatedAt.IsZero() {
|
|
||||||
t.CreatedAt = now
|
|
||||||
}
|
|
||||||
if t.UpdatedAt.IsZero() {
|
|
||||||
t.UpdatedAt = now
|
|
||||||
}
|
|
||||||
r.m[t.ID] = t
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockTaskRepo) ListRecent(ctx context.Context) ([]models.Task, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
result := make([]models.Task, 0, len(r.m))
|
|
||||||
for _, t := range r.m {
|
|
||||||
result = append(result, t)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockTaskRepo) Count(ctx context.Context) (int, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
return len(r.m), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Mock Project Repo ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type mockProjectRepo struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
m map[string]models.Project
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMockProjectRepo() *mockProjectRepo {
|
|
||||||
return &mockProjectRepo{m: make(map[string]models.Project)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockProjectRepo) Create(ctx context.Context, p models.Project) (models.Project, error) {
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if p.ID == "" {
|
|
||||||
p.ID = fmt.Sprintf("proj-%d", len(r.m)+1)
|
|
||||||
}
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if p.CreatedAt.IsZero() {
|
|
||||||
p.CreatedAt = now
|
|
||||||
}
|
|
||||||
if p.UpdatedAt.IsZero() {
|
|
||||||
p.UpdatedAt = now
|
|
||||||
}
|
|
||||||
if p.AgentIDs == nil {
|
|
||||||
p.AgentIDs = []string{}
|
|
||||||
}
|
|
||||||
r.m[p.ID] = p
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockProjectRepo) List(ctx context.Context) ([]models.Project, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
result := make([]models.Project, 0, len(r.m))
|
|
||||||
for _, p := range r.m {
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *mockProjectRepo) Count(ctx context.Context) (int, error) {
|
|
||||||
r.mu.RLock()
|
|
||||||
defer r.mu.RUnlock()
|
|
||||||
return len(r.m), nil
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
@@ -11,12 +10,7 @@ import (
|
|||||||
|
|
||||||
// ListProjects handles GET /api/projects.
|
// ListProjects handles GET /api/projects.
|
||||||
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||||
projects, err := h.Projects.List(r.Context())
|
projects := h.ProjectStore.List()
|
||||||
if err != nil {
|
|
||||||
slog.Error("list projects failed", "error", err)
|
|
||||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list projects"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if projects == nil {
|
if projects == nil {
|
||||||
projects = []models.Project{}
|
projects = []models.Project{}
|
||||||
}
|
}
|
||||||
@@ -24,10 +18,9 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
page, pageSize := parsePagination(r)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(projects), page, pageSize)
|
start, end := paginateSlice(len(projects), page, pageSize)
|
||||||
|
|
||||||
totalCount, _ := h.Projects.Count(r.Context())
|
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: projects[start:end],
|
Data: projects[start:end],
|
||||||
TotalCount: totalCount,
|
TotalCount: h.ProjectStore.Count(),
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(projects),
|
HasMore: end < len(projects),
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
@@ -11,12 +10,7 @@ import (
|
|||||||
|
|
||||||
// ListSessions handles GET /api/sessions.
|
// ListSessions handles GET /api/sessions.
|
||||||
func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
sessions, err := h.Sessions.ListActive(r.Context())
|
sessions := h.SessionStore.ListActive()
|
||||||
if err != nil {
|
|
||||||
slog.Error("list sessions failed", "error", err)
|
|
||||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list sessions"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if sessions == nil {
|
if sessions == nil {
|
||||||
sessions = []models.Session{}
|
sessions = []models.Session{}
|
||||||
}
|
}
|
||||||
@@ -24,10 +18,9 @@ func (h *Handler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
page, pageSize := parsePagination(r)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(sessions), page, pageSize)
|
start, end := paginateSlice(len(sessions), page, pageSize)
|
||||||
|
|
||||||
totalCount, _ := h.Sessions.Count(r.Context())
|
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: sessions[start:end],
|
Data: sessions[start:end],
|
||||||
TotalCount: totalCount,
|
TotalCount: h.SessionStore.Count(),
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(sessions),
|
HasMore: end < len(sessions),
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
// Package handler provides SSE (Server-Sent Events) streaming for the
|
|
||||||
// Control Center API. The Broker manages client connections and broadcasts
|
|
||||||
// typed events in text/event-stream format.
|
|
||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SSEEvent represents a single event to stream to connected clients.
|
|
||||||
type SSEEvent struct {
|
|
||||||
EventType string `json:"eventType"`
|
|
||||||
Data any `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broker manages SSE client connections and broadcasts events to all
|
|
||||||
// connected listeners. It is safe for concurrent use.
|
|
||||||
type Broker struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
clients map[chan SSEEvent]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBroker returns an initialized Broker.
|
|
||||||
func NewBroker() *Broker {
|
|
||||||
return &Broker{
|
|
||||||
clients: make(map[chan SSEEvent]struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe registers a new client channel. The caller must read from
|
|
||||||
// this channel and write SSE frames to the HTTP response writer.
|
|
||||||
func (b *Broker) Subscribe() chan SSEEvent {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
ch := make(chan SSEEvent, 32) // small buffer to avoid blocking bursts
|
|
||||||
b.clients[ch] = struct{}{}
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unsubscribe removes a client channel and closes it.
|
|
||||||
func (b *Broker) Unsubscribe(ch chan SSEEvent) {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
|
|
||||||
if _, ok := b.clients[ch]; ok {
|
|
||||||
delete(b.clients, ch)
|
|
||||||
close(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast sends evt to every connected client. Slow clients that cannot
|
|
||||||
// receive within their buffer are silently dropped (non-blocking send).
|
|
||||||
func (b *Broker) Broadcast(eventType string, data any) {
|
|
||||||
evt := SSEEvent{EventType: eventType, Data: data}
|
|
||||||
|
|
||||||
b.mu.RLock()
|
|
||||||
defer b.mu.RUnlock()
|
|
||||||
|
|
||||||
for ch := range b.clients {
|
|
||||||
select {
|
|
||||||
case ch <- evt:
|
|
||||||
default:
|
|
||||||
// Client too slow — drop this event for this client
|
|
||||||
slog.Warn("sse client buffer full, dropping event",
|
|
||||||
"eventType", eventType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClientCount returns the number of currently connected SSE clients.
|
|
||||||
func (b *Broker) ClientCount() int {
|
|
||||||
b.mu.RLock()
|
|
||||||
defer b.mu.RUnlock()
|
|
||||||
return len(b.clients)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP handles GET /api/events. It registers the client, streams
|
|
||||||
// events in text/event-stream format, and cleans up on disconnect.
|
|
||||||
func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Ensure we can flush
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE headers
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("X-Accel-Buffering", "no") // disable nginx buffering
|
|
||||||
|
|
||||||
ch := b.Subscribe()
|
|
||||||
defer b.Unsubscribe(ch)
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
fmt.Fprintf(w, "event: connected\ndata: {\"clientCount\":%d}\n\n", b.ClientCount())
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Client disconnected
|
|
||||||
slog.Debug("sse client disconnected")
|
|
||||||
return
|
|
||||||
case evt, ok := <-ch:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(evt.Data)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("sse marshal failed", "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", evt.EventType, string(data))
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
||||||
@@ -11,12 +10,7 @@ import (
|
|||||||
|
|
||||||
// ListTasks handles GET /api/tasks.
|
// ListTasks handles GET /api/tasks.
|
||||||
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
tasks, err := h.Tasks.ListRecent(r.Context())
|
tasks := h.TaskStore.ListRecent()
|
||||||
if err != nil {
|
|
||||||
slog.Error("list tasks failed", "error", err)
|
|
||||||
writeJSON(w, http.StatusInternalServerError, models.ErrorResponse{Error: "failed to list tasks"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if tasks == nil {
|
if tasks == nil {
|
||||||
tasks = []models.Task{}
|
tasks = []models.Task{}
|
||||||
}
|
}
|
||||||
@@ -24,10 +18,9 @@ func (h *Handler) ListTasks(w http.ResponseWriter, r *http.Request) {
|
|||||||
page, pageSize := parsePagination(r)
|
page, pageSize := parsePagination(r)
|
||||||
start, end := paginateSlice(len(tasks), page, pageSize)
|
start, end := paginateSlice(len(tasks), page, pageSize)
|
||||||
|
|
||||||
totalCount, _ := h.Tasks.Count(r.Context())
|
|
||||||
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
writeJSON(w, http.StatusOK, models.PaginatedResponse{
|
||||||
Data: tasks[start:end],
|
Data: tasks[start:end],
|
||||||
TotalCount: totalCount,
|
TotalCount: h.TaskStore.Count(),
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
HasMore: end < len(tasks),
|
HasMore: end < len(tasks),
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
// Package repository provides PostgreSQL-backed CRUD implementations
|
|
||||||
// for the Control Center domain entities. Each repository takes a
|
|
||||||
// *pgxpool.Pool in its constructor and uses pgx.CollectRows() for scanning.
|
|
||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AgentRepository provides PostgreSQL-backed CRUD for agents.
|
|
||||||
type AgentRepository struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAgentRepository returns a repository wired to the given connection pool.
|
|
||||||
func NewAgentRepository(pool *pgxpool.Pool) *AgentRepository {
|
|
||||||
return &AgentRepository{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new agent. It maps the models.AgentCardData fields onto
|
|
||||||
// the agents table columns (uuid id, text name, text status, text task,
|
|
||||||
// int progress, text session_key, text channel).
|
|
||||||
func (r *AgentRepository) Create(ctx context.Context, a models.AgentCardData) error {
|
|
||||||
prog := 0
|
|
||||||
if a.TaskProgress != nil {
|
|
||||||
prog = *a.TaskProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := r.pool.Exec(ctx, `
|
|
||||||
INSERT INTO agents (id, name, status, task, progress, session_key, channel, last_activity)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
`, a.ID, a.DisplayName, string(a.Status), a.CurrentTask, prog,
|
|
||||||
a.SessionKey, a.Channel, time.Now().UTC())
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a single agent by its string id.
|
|
||||||
func (r *AgentRepository) Get(ctx context.Context, id string) (models.AgentCardData, error) {
|
|
||||||
var a models.AgentCardData
|
|
||||||
var task *string
|
|
||||||
var prog int
|
|
||||||
var lastActivity time.Time
|
|
||||||
|
|
||||||
err := r.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
|
||||||
FROM agents WHERE id = $1
|
|
||||||
`, id).Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
|
|
||||||
&a.SessionKey, &a.Channel, &lastActivity)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return a, err
|
|
||||||
}
|
|
||||||
|
|
||||||
a.CurrentTask = task
|
|
||||||
if prog > 0 || task != nil {
|
|
||||||
p := prog
|
|
||||||
a.TaskProgress = &p
|
|
||||||
}
|
|
||||||
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
|
|
||||||
|
|
||||||
// Role is not persisted in the current schema — set a sensible default.
|
|
||||||
a.Role = "agent"
|
|
||||||
|
|
||||||
return a, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all agents, optionally filtered by status.
|
|
||||||
// Results are ordered by name (display_name).
|
|
||||||
func (r *AgentRepository) List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error) {
|
|
||||||
var rows pgx.Rows
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if statusFilter != "" {
|
|
||||||
rows, err = r.pool.Query(ctx, `
|
|
||||||
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
|
||||||
FROM agents WHERE status = $1 ORDER BY name
|
|
||||||
`, string(statusFilter))
|
|
||||||
} else {
|
|
||||||
rows, err = r.pool.Query(ctx, `
|
|
||||||
SELECT id, name, status, task, progress, session_key, channel, last_activity
|
|
||||||
FROM agents ORDER BY name
|
|
||||||
`)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.AgentCardData, error) {
|
|
||||||
var a models.AgentCardData
|
|
||||||
var task *string
|
|
||||||
var prog int
|
|
||||||
var lastActivity time.Time
|
|
||||||
|
|
||||||
if err := row.Scan(&a.ID, &a.DisplayName, &a.Status, &task, &prog,
|
|
||||||
&a.SessionKey, &a.Channel, &lastActivity); err != nil {
|
|
||||||
return a, err
|
|
||||||
}
|
|
||||||
|
|
||||||
a.CurrentTask = task
|
|
||||||
if prog > 0 || task != nil {
|
|
||||||
p := prog
|
|
||||||
a.TaskProgress = &p
|
|
||||||
}
|
|
||||||
a.LastActivity = lastActivity.UTC().Format(time.RFC3339)
|
|
||||||
a.Role = "agent"
|
|
||||||
return a, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies partial updates to an agent. Returns the updated agent.
|
|
||||||
func (r *AgentRepository) Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error) {
|
|
||||||
// Build dynamic SET clause.
|
|
||||||
setClauses := []string{"last_activity = $2"}
|
|
||||||
args := []any{id, time.Now().UTC()}
|
|
||||||
argIdx := 3
|
|
||||||
|
|
||||||
if req.Status != nil {
|
|
||||||
setClauses = append(setClauses, fmt.Sprintf("status = $%d", argIdx))
|
|
||||||
args = append(args, string(*req.Status))
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.CurrentTask != nil {
|
|
||||||
setClauses = append(setClauses, fmt.Sprintf("task = $%d", argIdx))
|
|
||||||
args = append(args, *req.CurrentTask)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.TaskProgress != nil {
|
|
||||||
setClauses = append(setClauses, fmt.Sprintf("progress = $%d", argIdx))
|
|
||||||
args = append(args, *req.TaskProgress)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
if req.Channel != nil {
|
|
||||||
setClauses = append(setClauses, fmt.Sprintf("channel = $%d", argIdx))
|
|
||||||
args = append(args, *req.Channel)
|
|
||||||
argIdx++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build and execute
|
|
||||||
query := "UPDATE agents SET "
|
|
||||||
for i, clause := range setClauses {
|
|
||||||
if i > 0 {
|
|
||||||
query += ", "
|
|
||||||
}
|
|
||||||
query += clause
|
|
||||||
}
|
|
||||||
query += " WHERE id = $1"
|
|
||||||
|
|
||||||
ct, err := r.pool.Exec(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return models.AgentCardData{}, err
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return models.AgentCardData{}, fmt.Errorf("agent not found: %s", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.Get(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes an agent. Returns nil even if the agent doesn't exist
|
|
||||||
// (idempotent). Returns a wrapped error only on transport failures.
|
|
||||||
func (r *AgentRepository) Delete(ctx context.Context, id string) error {
|
|
||||||
_, err := r.pool.Exec(ctx, `DELETE FROM agents WHERE id = $1`, id)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count returns the total number of agents.
|
|
||||||
func (r *AgentRepository) Count(ctx context.Context) (int, error) {
|
|
||||||
var n int
|
|
||||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents`).Scan(&n)
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountByStatus returns the number of agents with the given status.
|
|
||||||
func (r *AgentRepository) CountByStatus(ctx context.Context, status models.AgentStatus) (int, error) {
|
|
||||||
var n int
|
|
||||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM agents WHERE status = $1`, string(status)).Scan(&n)
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AgentRepo is the interface for agent persistence operations.
|
|
||||||
type AgentRepo interface {
|
|
||||||
Create(ctx context.Context, a models.AgentCardData) error
|
|
||||||
Get(ctx context.Context, id string) (models.AgentCardData, error)
|
|
||||||
List(ctx context.Context, statusFilter models.AgentStatus) ([]models.AgentCardData, error)
|
|
||||||
Update(ctx context.Context, id string, req models.UpdateAgentRequest) (models.AgentCardData, error)
|
|
||||||
Delete(ctx context.Context, id string) error
|
|
||||||
Count(ctx context.Context) (int, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionRepo is the interface for session persistence operations.
|
|
||||||
type SessionRepo interface {
|
|
||||||
Create(ctx context.Context, s models.Session) (models.Session, error)
|
|
||||||
ListActive(ctx context.Context) ([]models.Session, error)
|
|
||||||
Count(ctx context.Context) (int, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TaskRepo is the interface for task persistence operations.
|
|
||||||
type TaskRepo interface {
|
|
||||||
Create(ctx context.Context, t models.Task) (models.Task, error)
|
|
||||||
ListRecent(ctx context.Context) ([]models.Task, error)
|
|
||||||
Count(ctx context.Context) (int, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProjectRepo is the interface for project persistence operations.
|
|
||||||
type ProjectRepo interface {
|
|
||||||
Create(ctx context.Context, p models.Project) (models.Project, error)
|
|
||||||
List(ctx context.Context) ([]models.Project, error)
|
|
||||||
Count(ctx context.Context) (int, error)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProjectRepository provides PostgreSQL-backed CRUD for projects.
|
|
||||||
type ProjectRepository struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewProjectRepository returns a repository wired to the given connection pool.
|
|
||||||
func NewProjectRepository(pool *pgxpool.Pool) *ProjectRepository {
|
|
||||||
return &ProjectRepository{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new project. The current projects table only stores
|
|
||||||
// a single agent_id, so we use the first entry from AgentIDs if present.
|
|
||||||
func (r *ProjectRepository) Create(ctx context.Context, p models.Project) (models.Project, error) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if p.CreatedAt.IsZero() {
|
|
||||||
p.CreatedAt = now
|
|
||||||
}
|
|
||||||
if p.UpdatedAt.IsZero() {
|
|
||||||
p.UpdatedAt = now
|
|
||||||
}
|
|
||||||
|
|
||||||
var agentID *string
|
|
||||||
if len(p.AgentIDs) > 0 {
|
|
||||||
agentID = &p.AgentIDs[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO projects (name, description, status, agent_id, created_at, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id, name, description, status, agent_id, created_at, updated_at
|
|
||||||
`, p.Name, p.Description, string(p.Status), agentID, p.CreatedAt, p.UpdatedAt).Scan(
|
|
||||||
&p.ID, &p.Name, &p.Description, &p.Status, &agentID,
|
|
||||||
&p.CreatedAt, &p.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return p, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if agentID != nil {
|
|
||||||
p.AgentIDs = []string{*agentID}
|
|
||||||
} else {
|
|
||||||
p.AgentIDs = []string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// List returns all projects ordered by name.
|
|
||||||
func (r *ProjectRepository) List(ctx context.Context) ([]models.Project, error) {
|
|
||||||
rows, err := r.pool.Query(ctx, `
|
|
||||||
SELECT id, name, description, status, agent_id, created_at, updated_at
|
|
||||||
FROM projects
|
|
||||||
ORDER BY name
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Project, error) {
|
|
||||||
var p models.Project
|
|
||||||
var agentID *string
|
|
||||||
|
|
||||||
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.Status,
|
|
||||||
&agentID, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
||||||
return p, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if agentID != nil {
|
|
||||||
p.AgentIDs = []string{*agentID}
|
|
||||||
} else {
|
|
||||||
p.AgentIDs = []string{}
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count returns the total number of projects.
|
|
||||||
func (r *ProjectRepository) Count(ctx context.Context) (int, error) {
|
|
||||||
var n int
|
|
||||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM projects`).Scan(&n)
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SessionRepository provides PostgreSQL-backed CRUD for sessions.
|
|
||||||
type SessionRepository struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSessionRepository returns a repository wired to the given connection pool.
|
|
||||||
func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
|
|
||||||
return &SessionRepository{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new session into the sessions table.
|
|
||||||
// Because the existing sessions table only has id, agent_id, started_at,
|
|
||||||
// ended_at, and status, we map what we can and store additional metadata
|
|
||||||
// as a fallback. AgentID is required by FK — if the session AgentID can't
|
|
||||||
// be cast to a valid UUID we store a sentinel.
|
|
||||||
func (r *SessionRepository) Create(ctx context.Context, s models.Session) (models.Session, error) {
|
|
||||||
if s.StartedAt.IsZero() {
|
|
||||||
s.StartedAt = time.Now().UTC()
|
|
||||||
}
|
|
||||||
if s.LastActivityAt.IsZero() {
|
|
||||||
s.LastActivityAt = s.StartedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO sessions (agent_id, started_at, status)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, agent_id, started_at, ended_at, status
|
|
||||||
`, s.AgentID, s.StartedAt, s.Status).Scan(
|
|
||||||
&s.ID, &s.AgentID, &s.StartedAt, nil, &s.Status)
|
|
||||||
|
|
||||||
return s, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListActive returns all sessions with status 'running' or 'streaming',
|
|
||||||
// ordered by started_at descending.
|
|
||||||
func (r *SessionRepository) ListActive(ctx context.Context) ([]models.Session, error) {
|
|
||||||
rows, err := r.pool.Query(ctx, `
|
|
||||||
SELECT id, agent_id, started_at, ended_at, status
|
|
||||||
FROM sessions
|
|
||||||
WHERE status IN ('running', 'streaming')
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Session, error) {
|
|
||||||
var s models.Session
|
|
||||||
var endedAt *time.Time
|
|
||||||
if err := row.Scan(&s.ID, &s.AgentID, &s.StartedAt, &endedAt, &s.Status); err != nil {
|
|
||||||
return s, err
|
|
||||||
}
|
|
||||||
s.LastActivityAt = s.StartedAt
|
|
||||||
if endedAt != nil {
|
|
||||||
s.LastActivityAt = *endedAt
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count returns the total number of sessions.
|
|
||||||
func (r *SessionRepository) Count(ctx context.Context) (int, error) {
|
|
||||||
var n int
|
|
||||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM sessions`).Scan(&n)
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"code.cubecraftcreations.com/CubeCraft-Creations/Control-Center/go-backend/internal/models"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TaskRepository provides PostgreSQL-backed CRUD for task_logs.
|
|
||||||
type TaskRepository struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTaskRepository returns a repository wired to the given connection pool.
|
|
||||||
func NewTaskRepository(pool *pgxpool.Pool) *TaskRepository {
|
|
||||||
return &TaskRepository{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new task into the task_logs table.
|
|
||||||
func (r *TaskRepository) Create(ctx context.Context, t models.Task) (models.Task, error) {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
if t.CreatedAt.IsZero() {
|
|
||||||
t.CreatedAt = now
|
|
||||||
}
|
|
||||||
if t.UpdatedAt.IsZero() {
|
|
||||||
t.UpdatedAt = now
|
|
||||||
}
|
|
||||||
|
|
||||||
err := r.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO task_logs (agent_id, task, status, started_at)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, agent_id, task, status, started_at, completed_at, error_message
|
|
||||||
`, t.AgentID, t.Title, string(t.Status), t.CreatedAt).Scan(
|
|
||||||
&t.ID, &t.AgentID, &t.Title, &t.Status, &t.CreatedAt,
|
|
||||||
nil, nil,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild the Description since task_logs only stores the title as "task".
|
|
||||||
t.Description = t.Title
|
|
||||||
return t, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRecent returns the most recent tasks, newest first.
|
|
||||||
func (r *TaskRepository) ListRecent(ctx context.Context) ([]models.Task, error) {
|
|
||||||
rows, err := r.pool.Query(ctx, `
|
|
||||||
SELECT id, agent_id, task, status, started_at, completed_at, error_message
|
|
||||||
FROM task_logs
|
|
||||||
ORDER BY started_at DESC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return pgx.CollectRows(rows, func(row pgx.CollectableRow) (models.Task, error) {
|
|
||||||
var t models.Task
|
|
||||||
var completedAt *time.Time
|
|
||||||
var errMsg *string
|
|
||||||
|
|
||||||
if err := row.Scan(&t.ID, &t.AgentID, &t.Title, &t.Status,
|
|
||||||
&t.CreatedAt, &completedAt, &errMsg); err != nil {
|
|
||||||
return t, err
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Description = t.Title
|
|
||||||
t.UpdatedAt = t.CreatedAt
|
|
||||||
if completedAt != nil {
|
|
||||||
t.UpdatedAt = *completedAt
|
|
||||||
}
|
|
||||||
return t, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count returns the total number of tasks.
|
|
||||||
func (r *TaskRepository) Count(ctx context.Context) (int, error) {
|
|
||||||
var n int
|
|
||||||
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM task_logs`).Scan(&n)
|
|
||||||
return n, err
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,13 +13,11 @@ import (
|
|||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Dependencies carries the handler, database pool, SSE broker, and CORS
|
// Dependencies carries the handler and database pool into the router.
|
||||||
// configuration into the router.
|
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
Handler *handler.Handler
|
Handler *handler.Handler
|
||||||
Pool *db.Pool
|
DB *db.Pool
|
||||||
CORSOrigin string
|
CORSOrigin string
|
||||||
Broker *handler.Broker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a fully-configured chi router with all API routes mounted.
|
// New creates a fully-configured chi router with all API routes mounted.
|
||||||
@@ -52,10 +49,8 @@ func New(deps *Dependencies) *chi.Mux {
|
|||||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
status := "ok"
|
status := "ok"
|
||||||
if deps.Pool != nil {
|
if deps.DB != nil {
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
|
if err := deps.DB.Health(r.Context()); err != nil {
|
||||||
defer cancel()
|
|
||||||
if err := deps.Pool.Ping(ctx); err != nil {
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
status = "db_unhealthy"
|
status = "db_unhealthy"
|
||||||
}
|
}
|
||||||
@@ -83,9 +78,6 @@ func New(deps *Dependencies) *chi.Mux {
|
|||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
api.Get("/projects", deps.Handler.ListProjects)
|
api.Get("/projects", deps.Handler.ListProjects)
|
||||||
|
|
||||||
// SSE event stream
|
|
||||||
api.Get("/events", deps.Broker.ServeHTTP)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
Reference in New Issue
Block a user