Initial dashboard skill - Vite+React+Tailwind web UI
This commit is contained in:
13
ui/index.html
Normal file
13
ui/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VibeStack Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
ui/package.json
Normal file
29
ui/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "vibestack-dashboard",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src --ext ts,tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"clsx": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
6
ui/postcss.config.js
Normal file
6
ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
25
ui/src/App.tsx
Normal file
25
ui/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { Layout } from './components/Layout'
|
||||
import { Overview } from './pages/Overview'
|
||||
import { Skills } from './pages/Skills'
|
||||
import { SkillDetail } from './pages/SkillDetail'
|
||||
import { Memory } from './pages/Memory'
|
||||
import { Backups } from './pages/Backups'
|
||||
import { Logs } from './pages/Logs'
|
||||
import { Settings } from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Overview />} />
|
||||
<Route path="skills" element={<Skills />} />
|
||||
<Route path="skills/:name" element={<SkillDetail />} />
|
||||
<Route path="memory" element={<Memory />} />
|
||||
<Route path="backups" element={<Backups />} />
|
||||
<Route path="logs" element={<Logs />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
29
ui/src/components/Card.tsx
Normal file
29
ui/src/components/Card.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Card({ children, className }: CardProps) {
|
||||
return (
|
||||
<div className={clsx('bg-gray-900 border border-gray-800 rounded-xl p-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export function CardHeader({ title, action }: CardHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
ui/src/components/Layout.tsx
Normal file
13
ui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
export function Layout() {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
61
ui/src/components/Sidebar.tsx
Normal file
61
ui/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Blocks,
|
||||
Brain,
|
||||
Archive,
|
||||
ScrollText,
|
||||
Settings,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: 'Overview' },
|
||||
{ to: '/skills', icon: Blocks, label: 'Skills' },
|
||||
{ to: '/memory', icon: Brain, label: 'Memory' },
|
||||
{ to: '/backups', icon: Archive, label: 'Backups' },
|
||||
{ to: '/logs', icon: ScrollText, label: 'Logs' },
|
||||
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="w-56 bg-gray-900 border-r border-gray-800 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-800">
|
||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-purple-500 to-blue-500 rounded-lg" />
|
||||
VibeStack
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-3 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-gray-800 text-white'
|
||||
: 'text-gray-400 hover:text-white hover:bg-gray-800/50'
|
||||
)
|
||||
}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-3 border-t border-gray-800">
|
||||
<button className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800/50 transition-colors">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Skill
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
35
ui/src/components/StatusBadge.tsx
Normal file
35
ui/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
type Status = 'running' | 'stopped' | 'error' | 'pending'
|
||||
|
||||
const statusStyles: Record<Status, string> = {
|
||||
running: 'bg-green-500/20 text-green-400 border-green-500/30',
|
||||
stopped: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
|
||||
error: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status
|
||||
label?: string
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: StatusBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border',
|
||||
statusStyles[status]
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status === 'running' && 'bg-green-400 animate-pulse',
|
||||
status === 'stopped' && 'bg-gray-400',
|
||||
status === 'error' && 'bg-red-400',
|
||||
status === 'pending' && 'bg-yellow-400 animate-pulse'
|
||||
)} />
|
||||
{label || status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
4
ui/src/components/index.ts
Normal file
4
ui/src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { Layout } from './Layout'
|
||||
export { Sidebar } from './Sidebar'
|
||||
export { Card, CardHeader } from './Card'
|
||||
export { StatusBadge } from './StatusBadge'
|
||||
11
ui/src/index.css
Normal file
11
ui/src/index.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-950 text-gray-100;
|
||||
}
|
||||
162
ui/src/lib/api.ts
Normal file
162
ui/src/lib/api.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
const API_BASE = '/api'
|
||||
|
||||
export interface Skill {
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
status: 'running' | 'stopped' | 'error'
|
||||
requires: string[]
|
||||
hasRunScript: boolean
|
||||
metricsPort?: number
|
||||
}
|
||||
|
||||
export interface SkillDetail extends Skill {
|
||||
readme: string
|
||||
config: Record<string, string>
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
export interface ProcessStatus {
|
||||
name: string
|
||||
state: string
|
||||
pid: number
|
||||
uptime: number
|
||||
exitcode: number | null
|
||||
}
|
||||
|
||||
export interface Memory {
|
||||
id: string
|
||||
type: string
|
||||
content: string
|
||||
metadata: Record<string, unknown>
|
||||
similarity?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface BackupStatus {
|
||||
status: string
|
||||
schedule: string
|
||||
target: string
|
||||
last_backup: string | null
|
||||
last_status: string | null
|
||||
snapshot_id?: string
|
||||
}
|
||||
|
||||
export interface SystemStats {
|
||||
cpu_percent: number
|
||||
memory_used: number
|
||||
memory_total: number
|
||||
disk_used: number
|
||||
disk_total: number
|
||||
}
|
||||
|
||||
export interface CaddyConfig {
|
||||
domains: Array<{
|
||||
domain: string
|
||||
backend: string
|
||||
tls: boolean
|
||||
}>
|
||||
snippets: string[]
|
||||
}
|
||||
|
||||
// Skills API
|
||||
export async function fetchSkills(): Promise<Skill[]> {
|
||||
const res = await fetch(`${API_BASE}/skills`)
|
||||
if (!res.ok) throw new Error('Failed to fetch skills')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchSkill(name: string): Promise<SkillDetail> {
|
||||
const res = await fetch(`${API_BASE}/skills/${name}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch skill')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Supervisor API
|
||||
export async function fetchSupervisorStatus(): Promise<ProcessStatus[]> {
|
||||
const res = await fetch(`${API_BASE}/supervisor/status`)
|
||||
if (!res.ok) throw new Error('Failed to fetch supervisor status')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function restartProcess(name: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/supervisor/${name}/restart`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Failed to restart process')
|
||||
}
|
||||
|
||||
export async function stopProcess(name: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/supervisor/${name}/stop`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Failed to stop process')
|
||||
}
|
||||
|
||||
export async function startProcess(name: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/supervisor/${name}/start`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Failed to start process')
|
||||
}
|
||||
|
||||
// Memory API
|
||||
export async function searchMemory(query: string, type?: string): Promise<Memory[]> {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
if (type) params.set('type', type)
|
||||
const res = await fetch(`${API_BASE}/memory/search?${params}`)
|
||||
if (!res.ok) throw new Error('Failed to search memory')
|
||||
const data = await res.json()
|
||||
return data.results
|
||||
}
|
||||
|
||||
export async function fetchMemories(type?: string, limit = 50): Promise<Memory[]> {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (type) params.set('type', type)
|
||||
const res = await fetch(`${API_BASE}/memory?${params}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch memories')
|
||||
const data = await res.json()
|
||||
return data.results
|
||||
}
|
||||
|
||||
export async function createMemory(memory: { type: string; content: string; metadata?: Record<string, unknown> }): Promise<Memory> {
|
||||
const res = await fetch(`${API_BASE}/memory`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(memory),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to create memory')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteMemory(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/memory/${id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Failed to delete memory')
|
||||
}
|
||||
|
||||
// Backup API
|
||||
export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||||
const res = await fetch(`${API_BASE}/backup/status`)
|
||||
if (!res.ok) throw new Error('Failed to fetch backup status')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function triggerBackup(): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/backup/trigger`, { method: 'POST' })
|
||||
if (!res.ok) throw new Error('Failed to trigger backup')
|
||||
}
|
||||
|
||||
// System API
|
||||
export async function fetchSystemStats(): Promise<SystemStats> {
|
||||
const res = await fetch(`${API_BASE}/system/stats`)
|
||||
if (!res.ok) throw new Error('Failed to fetch system stats')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Caddy API
|
||||
export async function fetchCaddyConfig(): Promise<CaddyConfig> {
|
||||
const res = await fetch(`${API_BASE}/caddy/config`)
|
||||
if (!res.ok) throw new Error('Failed to fetch caddy config')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Logs API
|
||||
export async function fetchLogs(skill: string, lines = 100): Promise<string[]> {
|
||||
const res = await fetch(`${API_BASE}/logs/${skill}?lines=${lines}`)
|
||||
if (!res.ok) throw new Error('Failed to fetch logs')
|
||||
return res.json()
|
||||
}
|
||||
26
ui/src/main.tsx
Normal file
26
ui/src/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 10000,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
216
ui/src/pages/Backups.tsx
Normal file
216
ui/src/pages/Backups.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Archive, Play, Clock, CheckCircle, AlertCircle, HardDrive } from 'lucide-react'
|
||||
import { Card, CardHeader } from '../components/Card'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import { fetchBackupStatus, triggerBackup } from '../lib/api'
|
||||
|
||||
export function Backups() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: status, isLoading } = useQuery({
|
||||
queryKey: ['backup'],
|
||||
queryFn: fetchBackupStatus,
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: triggerBackup,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['backup'] }),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Backups</h1>
|
||||
<button
|
||||
onClick={() => triggerMutation.mutate()}
|
||||
disabled={triggerMutation.isPending || status?.status === 'running'}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{triggerMutation.isPending ? 'Starting...' : 'Backup Now'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-lg ${status?.last_status === 'success' ? 'bg-green-500/20' : 'bg-yellow-500/20'}`}>
|
||||
{status?.last_status === 'success' ? (
|
||||
<CheckCircle className="w-6 h-6 text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Last Backup</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{status?.last_backup ? formatTimeAgo(status.last_backup) : 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-500/20 rounded-lg">
|
||||
<Clock className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Schedule</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{status?.schedule || 'Not configured'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-purple-500/20 rounded-lg">
|
||||
<HardDrive className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Target</p>
|
||||
<p className="text-lg font-semibold text-white truncate">
|
||||
{status?.target || '/backups'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Current Status */}
|
||||
{status?.status === 'running' && (
|
||||
<Card className="border-purple-500/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
|
||||
<div>
|
||||
<p className="text-white font-medium">Backup in progress...</p>
|
||||
<p className="text-sm text-gray-400">This may take a few minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* What gets backed up */}
|
||||
<Card>
|
||||
<CardHeader title="Backup Includes" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ path: '/data/postgres', desc: 'PostgreSQL database (via pg_dump)' },
|
||||
{ path: '/data/redis', desc: 'Redis persistence files' },
|
||||
{ path: '/data/duckdb', desc: 'DuckDB databases' },
|
||||
{ path: '/data/loki', desc: 'Log data' },
|
||||
{ path: '/data/caddy', desc: 'TLS certificates' },
|
||||
{ path: '/personalities', desc: 'Agent personality configs' },
|
||||
{ path: '/workspaces', desc: 'Agent workspaces' },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center gap-3 p-3 bg-gray-800/50 rounded-lg"
|
||||
>
|
||||
<Archive className="w-4 h-4 text-gray-500" />
|
||||
<div>
|
||||
<p className="text-sm font-mono text-white">{item.path}</p>
|
||||
<p className="text-xs text-gray-500">{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recent Backups */}
|
||||
<Card>
|
||||
<CardHeader title="Recent Backups" />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
|
||||
<th className="pb-3 font-medium">Time</th>
|
||||
<th className="pb-3 font-medium">Status</th>
|
||||
<th className="pb-3 font-medium">Snapshot ID</th>
|
||||
<th className="pb-3 font-medium">Duration</th>
|
||||
<th className="pb-3 font-medium">Size Added</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-sm">
|
||||
{status?.last_backup ? (
|
||||
<tr className="border-b border-gray-800/50">
|
||||
<td className="py-3 text-white">
|
||||
{formatDate(status.last_backup)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<StatusBadge
|
||||
status={status.last_status === 'success' ? 'running' : 'error'}
|
||||
label={status.last_status || 'unknown'}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-3 font-mono text-gray-400">
|
||||
{status.snapshot_id || '-'}
|
||||
</td>
|
||||
<td className="py-3 text-gray-400">-</td>
|
||||
<td className="py-3 text-gray-400">-</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
No backups yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Restore Section */}
|
||||
<Card>
|
||||
<CardHeader title="Restore" />
|
||||
<p className="text-gray-400 mb-4">
|
||||
To restore from a backup, use the CLI:
|
||||
</p>
|
||||
<pre className="bg-gray-950 p-4 rounded-lg text-sm text-gray-300 overflow-x-auto">
|
||||
{`# List available snapshots
|
||||
/skills/backup/scripts/restore.sh --list
|
||||
|
||||
# Restore latest snapshot
|
||||
/skills/backup/scripts/restore.sh --latest
|
||||
|
||||
# Restore specific path
|
||||
/skills/backup/scripts/restore.sh --latest --path /data/postgres`}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (seconds < 60) return 'Just now'
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
|
||||
return `${Math.floor(seconds / 86400)} days ago`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
184
ui/src/pages/Logs.tsx
Normal file
184
ui/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Terminal, RefreshCw, Download, Pause, Play } from 'lucide-react'
|
||||
import { Card, CardHeader } from '../components/Card'
|
||||
import { fetchSkills, fetchLogs } from '../lib/api'
|
||||
|
||||
export function Logs() {
|
||||
const [selectedSkill, setSelectedSkill] = useState<string>('all')
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
const [filter, setFilter] = useState('')
|
||||
const logsEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { data: skills } = useQuery({
|
||||
queryKey: ['skills'],
|
||||
queryFn: fetchSkills,
|
||||
})
|
||||
|
||||
const { data: logs, refetch } = useQuery({
|
||||
queryKey: ['logs', selectedSkill],
|
||||
queryFn: () => fetchLogs(selectedSkill, 200),
|
||||
refetchInterval: isPaused ? false : 2000,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaused) {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [logs, isPaused])
|
||||
|
||||
const filteredLogs = logs?.filter(line =>
|
||||
filter ? line.toLowerCase().includes(filter.toLowerCase()) : true
|
||||
)
|
||||
|
||||
const downloadLogs = () => {
|
||||
if (!logs) return
|
||||
const blob = new Blob([logs.join('\n')], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${selectedSkill}-logs-${new Date().toISOString()}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Logs</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||
isPaused
|
||||
? 'bg-green-600 hover:bg-green-700 text-white'
|
||||
: 'bg-yellow-600 hover:bg-yellow-700 text-white'
|
||||
}`}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadLogs}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="flex-shrink-0">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-1">Skill</label>
|
||||
<select
|
||||
value={selectedSkill}
|
||||
onChange={(e) => setSelectedSkill(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="all">All Skills</option>
|
||||
{skills?.map((skill) => (
|
||||
<option key={skill.name} value={skill.name}>
|
||||
{skill.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-gray-500 mb-1">Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter logs..."
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Log Output */}
|
||||
<Card className="flex-1 min-h-0 flex flex-col">
|
||||
<CardHeader
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-gray-500" />
|
||||
<span>Output</span>
|
||||
{!isPaused && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-400">
|
||||
<span className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 bg-gray-950 rounded-lg p-4 font-mono text-xs overflow-auto">
|
||||
{filteredLogs?.length === 0 ? (
|
||||
<p className="text-gray-500">No logs available</p>
|
||||
) : (
|
||||
<>
|
||||
{filteredLogs?.map((line, i) => (
|
||||
<LogLine key={i} line={line} filter={filter} />
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LogLine({ line, filter }: { line: string; filter: string }) {
|
||||
// Detect log level
|
||||
const isError = /error|exception|fail/i.test(line)
|
||||
const isWarning = /warn/i.test(line)
|
||||
const isInfo = /info/i.test(line)
|
||||
const isDebug = /debug/i.test(line)
|
||||
|
||||
let colorClass = 'text-gray-400'
|
||||
if (isError) colorClass = 'text-red-400'
|
||||
else if (isWarning) colorClass = 'text-yellow-400'
|
||||
else if (isInfo) colorClass = 'text-blue-400'
|
||||
else if (isDebug) colorClass = 'text-gray-500'
|
||||
|
||||
// Highlight filter matches
|
||||
if (filter) {
|
||||
const regex = new RegExp(`(${filter})`, 'gi')
|
||||
const parts = line.split(regex)
|
||||
return (
|
||||
<div className={`${colorClass} hover:bg-gray-900/50`}>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === filter.toLowerCase() ? (
|
||||
<span key={i} className="bg-yellow-500/30 text-yellow-200">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className={`${colorClass} hover:bg-gray-900/50`}>{line}</div>
|
||||
}
|
||||
271
ui/src/pages/Memory.tsx
Normal file
271
ui/src/pages/Memory.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Search, Plus, Trash2, MessageSquare, Lightbulb, History, Brain } from 'lucide-react'
|
||||
import { Card, CardHeader } from '../components/Card'
|
||||
import { searchMemory, fetchMemories, createMemory, deleteMemory } from '../lib/api'
|
||||
import type { Memory as MemoryType } from '../lib/api'
|
||||
|
||||
const typeIcons: Record<string, React.ElementType> = {
|
||||
conversation: MessageSquare,
|
||||
finding: Lightbulb,
|
||||
execution: History,
|
||||
memory: Brain,
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
conversation: 'text-blue-400 bg-blue-500/20',
|
||||
finding: 'text-yellow-400 bg-yellow-500/20',
|
||||
execution: 'text-purple-400 bg-purple-500/20',
|
||||
memory: 'text-green-400 bg-green-500/20',
|
||||
}
|
||||
|
||||
export function Memory() {
|
||||
const queryClient = useQueryClient()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedType, setSelectedType] = useState<string | undefined>()
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
|
||||
const { data: memories, isLoading } = useQuery({
|
||||
queryKey: ['memories', selectedType, searchQuery, isSearching],
|
||||
queryFn: () =>
|
||||
isSearching && searchQuery
|
||||
? searchMemory(searchQuery, selectedType)
|
||||
: fetchMemories(selectedType),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteMemory,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['memories'] }),
|
||||
})
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSearching(true)
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('')
|
||||
setIsSearching(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Memory</h1>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Memory
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search & Filters */}
|
||||
<Card>
|
||||
<form onSubmit={handleSearch} className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search memories semantically..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedType || ''}
|
||||
onChange={(e) => setSelectedType(e.target.value || undefined)}
|
||||
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
<option value="conversation">Conversation</option>
|
||||
<option value="finding">Finding</option>
|
||||
<option value="execution">Execution</option>
|
||||
<option value="memory">Memory</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
{isSearching && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSearch}
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Results */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{memories?.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-center text-gray-400 py-8">
|
||||
{isSearching ? 'No memories found matching your search' : 'No memories yet'}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
memories?.map((memory) => (
|
||||
<MemoryCard
|
||||
key={memory.id}
|
||||
memory={memory}
|
||||
onDelete={() => deleteMutation.mutate(memory.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Memory Modal */}
|
||||
{showAddModal && (
|
||||
<AddMemoryModal onClose={() => setShowAddModal(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryCard({
|
||||
memory,
|
||||
onDelete,
|
||||
}: {
|
||||
memory: MemoryType
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const Icon = typeIcons[memory.type] || Brain
|
||||
const colorClass = typeColors[memory.type] || 'text-gray-400 bg-gray-500/20'
|
||||
|
||||
return (
|
||||
<Card className="hover:border-gray-700 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-2 rounded-lg ${colorClass}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-medium text-gray-400 uppercase">
|
||||
{memory.type}
|
||||
</span>
|
||||
{memory.similarity !== undefined && (
|
||||
<span className="text-xs text-purple-400">
|
||||
{Math.round(memory.similarity * 100)}% match
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-auto">
|
||||
{formatDate(memory.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white whitespace-pre-wrap">{memory.content}</p>
|
||||
{Object.keys(memory.metadata).length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{Object.entries(memory.metadata).map(([key, value]) => (
|
||||
<span
|
||||
key={key}
|
||||
className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded"
|
||||
>
|
||||
{key}: {String(value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-2 text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AddMemoryModal({ onClose }: { onClose: () => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [type, setType] = useState('finding')
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: createMemory,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['memories'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
mutation.mutate({ type, content })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader title="Add Memory" />
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Type</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="conversation">Conversation</option>
|
||||
<option value="finding">Finding</option>
|
||||
<option value="execution">Execution</option>
|
||||
<option value="memory">Memory</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">Content</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="What do you want to remember?"
|
||||
rows={4}
|
||||
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || mutation.isPending}
|
||||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
278
ui/src/pages/Overview.tsx
Normal file
278
ui/src/pages/Overview.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Blocks,
|
||||
Brain,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Clock
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader } from '../components/Card'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import { fetchSkills, fetchBackupStatus, fetchSystemStats, fetchSupervisorStatus } from '../lib/api'
|
||||
|
||||
export function Overview() {
|
||||
const { data: skills } = useQuery({
|
||||
queryKey: ['skills'],
|
||||
queryFn: fetchSkills,
|
||||
})
|
||||
|
||||
const { data: processes } = useQuery({
|
||||
queryKey: ['supervisor'],
|
||||
queryFn: fetchSupervisorStatus,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const { data: backup } = useQuery({
|
||||
queryKey: ['backup'],
|
||||
queryFn: fetchBackupStatus,
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['system-stats'],
|
||||
queryFn: fetchSystemStats,
|
||||
refetchInterval: 10000,
|
||||
})
|
||||
|
||||
const runningCount = processes?.filter(p => p.state === 'RUNNING').length ?? 0
|
||||
const stoppedCount = (processes?.length ?? 0) - runningCount
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Overview</h1>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-purple-500/20 rounded-lg">
|
||||
<Blocks className="w-6 h-6 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Skills</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{runningCount} <span className="text-sm text-gray-500">/ {skills?.length ?? 0}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-500/20 rounded-lg">
|
||||
<Brain className="w-6 h-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Memories</p>
|
||||
<p className="text-2xl font-bold text-white">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`p-3 rounded-lg ${backup?.last_status === 'success' ? 'bg-green-500/20' : 'bg-yellow-500/20'}`}>
|
||||
<Archive className={`w-6 h-6 ${backup?.last_status === 'success' ? 'text-green-400' : 'text-yellow-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Last Backup</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{backup?.last_backup ? formatTimeAgo(backup.last_backup) : 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-cyan-500/20 rounded-lg">
|
||||
<HardDrive className="w-6 h-6 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Disk Usage</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
{stats ? `${Math.round((stats.disk_used / stats.disk_total) * 100)}%` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Skills Grid */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Active Skills"
|
||||
action={
|
||||
<Link to="/skills" className="text-sm text-gray-400 hover:text-white">
|
||||
View all →
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{processes?.slice(0, 8).map((process) => (
|
||||
<Link
|
||||
key={process.name}
|
||||
to={`/skills/${process.name}`}
|
||||
className="p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-white">{process.name}</span>
|
||||
<StatusBadge
|
||||
status={process.state === 'RUNNING' ? 'running' : 'stopped'}
|
||||
/>
|
||||
</div>
|
||||
{process.state === 'RUNNING' && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Up {formatDuration(process.uptime)}
|
||||
</p>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* System Resources */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader title="System Resources" />
|
||||
<div className="space-y-4">
|
||||
<ResourceBar
|
||||
icon={Cpu}
|
||||
label="CPU"
|
||||
value={stats?.cpu_percent ?? 0}
|
||||
color="purple"
|
||||
/>
|
||||
<ResourceBar
|
||||
icon={MemoryStick}
|
||||
label="Memory"
|
||||
value={stats ? (stats.memory_used / stats.memory_total) * 100 : 0}
|
||||
detail={stats ? `${formatBytes(stats.memory_used)} / ${formatBytes(stats.memory_total)}` : undefined}
|
||||
color="blue"
|
||||
/>
|
||||
<ResourceBar
|
||||
icon={HardDrive}
|
||||
label="Disk"
|
||||
value={stats ? (stats.disk_used / stats.disk_total) * 100 : 0}
|
||||
detail={stats ? `${formatBytes(stats.disk_used)} / ${formatBytes(stats.disk_total)}` : undefined}
|
||||
color="cyan"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="Recent Activity" />
|
||||
<div className="space-y-3">
|
||||
<ActivityItem
|
||||
icon={CheckCircle}
|
||||
iconColor="text-green-400"
|
||||
title="Backup completed"
|
||||
time="2 hours ago"
|
||||
/>
|
||||
<ActivityItem
|
||||
icon={Blocks}
|
||||
iconColor="text-purple-400"
|
||||
title="Skill 'redis' restarted"
|
||||
time="5 hours ago"
|
||||
/>
|
||||
<ActivityItem
|
||||
icon={Brain}
|
||||
iconColor="text-blue-400"
|
||||
title="15 new memories added"
|
||||
time="1 day ago"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ResourceBar({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ElementType
|
||||
label: string
|
||||
value: number
|
||||
detail?: string
|
||||
color: 'purple' | 'blue' | 'cyan'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
purple: 'bg-purple-500',
|
||||
blue: 'bg-blue-500',
|
||||
cyan: 'bg-cyan-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</div>
|
||||
<span className="text-sm text-white">{Math.round(value)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${colorClasses[color]} transition-all`}
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
/>
|
||||
</div>
|
||||
{detail && <p className="text-xs text-gray-500 mt-1">{detail}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityItem({
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
title,
|
||||
time,
|
||||
}: {
|
||||
icon: React.ElementType
|
||||
iconColor: string
|
||||
title: string
|
||||
time: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={`w-4 h-4 ${iconColor}`} />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-white">{title}</p>
|
||||
<p className="text-xs text-gray-500">{time}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (seconds < 60) return 'Just now'
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
||||
return `${Math.floor(seconds / 86400)}d ago`
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`
|
||||
return `${Math.floor(seconds / 86400)}d`
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
202
ui/src/pages/Settings.tsx
Normal file
202
ui/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Globe, Shield, Server, FileCode, ExternalLink } from 'lucide-react'
|
||||
import { Card, CardHeader } from '../components/Card'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import { fetchCaddyConfig, fetchSystemStats } from '../lib/api'
|
||||
|
||||
export function Settings() {
|
||||
const { data: caddy, isLoading: caddyLoading } = useQuery({
|
||||
queryKey: ['caddy'],
|
||||
queryFn: fetchCaddyConfig,
|
||||
})
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['system-stats'],
|
||||
queryFn: fetchSystemStats,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
|
||||
{/* Caddy Configuration */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Caddy Configuration"
|
||||
action={<Globe className="w-4 h-4 text-gray-500" />}
|
||||
/>
|
||||
|
||||
{caddyLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-500" />
|
||||
</div>
|
||||
) : caddy?.domains.length === 0 ? (
|
||||
<p className="text-gray-400">No domains configured</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{caddy?.domains.map((domain) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{domain.tls ? (
|
||||
<Shield className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<Globe className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<div>
|
||||
<a
|
||||
href={`${domain.tls ? 'https' : 'http'}://${domain.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white hover:text-purple-400 flex items-center gap-1"
|
||||
>
|
||||
{domain.domain}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<p className="text-xs text-gray-500">{domain.backend}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={domain.tls ? 'running' : 'pending'}
|
||||
label={domain.tls ? 'TLS' : 'HTTP'}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{caddy?.snippets && caddy.snippets.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-800">
|
||||
<p className="text-sm text-gray-400 mb-2">Active Snippets:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{caddy.snippets.map((snippet) => (
|
||||
<span
|
||||
key={snippet}
|
||||
className="px-2 py-1 bg-gray-800 text-gray-400 text-xs rounded flex items-center gap-1"
|
||||
>
|
||||
<FileCode className="w-3 h-3" />
|
||||
{snippet}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* System Information */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="System Information"
|
||||
action={<Server className="w-4 h-4 text-gray-500" />}
|
||||
/>
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Hostname</dt>
|
||||
<dd className="text-white font-mono">{window.location.hostname}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Memory</dt>
|
||||
<dd className="text-white">
|
||||
{stats
|
||||
? `${formatBytes(stats.memory_used)} / ${formatBytes(stats.memory_total)}`
|
||||
: '-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Disk</dt>
|
||||
<dd className="text-white">
|
||||
{stats
|
||||
? `${formatBytes(stats.disk_used)} / ${formatBytes(stats.disk_total)}`
|
||||
: '-'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">CPU Usage</dt>
|
||||
<dd className="text-white">
|
||||
{stats ? `${Math.round(stats.cpu_percent)}%` : '-'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<Card>
|
||||
<CardHeader title="Environment" />
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ key: 'SKILLS_DIR', value: '/skills', desc: 'Skills installation directory' },
|
||||
{ key: 'SUPERVISOR_URL', value: 'http://localhost:9001', desc: 'Supervisor API' },
|
||||
{ key: 'MEMORY_URL', value: 'http://localhost:8081', desc: 'Memory API' },
|
||||
{ key: 'DASHBOARD_PORT', value: '3000', desc: 'Dashboard port' },
|
||||
].map((env) => (
|
||||
<div
|
||||
key={env.key}
|
||||
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-mono text-purple-400">{env.key}</p>
|
||||
<p className="text-xs text-gray-500">{env.desc}</p>
|
||||
</div>
|
||||
<code className="text-sm text-white bg-gray-900 px-2 py-1 rounded">
|
||||
{env.value}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Skill Management */}
|
||||
<Card>
|
||||
<CardHeader title="Skill Management" />
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-400">
|
||||
Skills can be added, updated, or removed using the skill management tools.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-dashed border-gray-700">
|
||||
<h4 className="font-medium text-white mb-2">skill-downloader</h4>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Download and install skills from git repositories.
|
||||
</p>
|
||||
<span className="text-xs text-yellow-400">Coming soon</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-800/50 rounded-lg border border-dashed border-gray-700">
|
||||
<h4 className="font-medium text-white mb-2">skill-creator</h4>
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Create new skills from templates or AI generation.
|
||||
</p>
|
||||
<span className="text-xs text-yellow-400">Coming soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* About */}
|
||||
<Card>
|
||||
<CardHeader title="About" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-500 to-blue-500 rounded-xl" />
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">VibeStack Dashboard</h3>
|
||||
<p className="text-sm text-gray-400">v1.0.0</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-400">
|
||||
Self-managing agent machine dashboard. Control your skills, browse memories,
|
||||
manage backups, and monitor system health.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
208
ui/src/pages/SkillDetail.tsx
Normal file
208
ui/src/pages/SkillDetail.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Play, Square, RotateCw, FileText, Settings, Terminal } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Card, CardHeader } from '../components/Card'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import { fetchSkill, fetchLogs, fetchSupervisorStatus } from '../lib/api'
|
||||
|
||||
export function SkillDetail() {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
|
||||
const { data: skill, isLoading } = useQuery({
|
||||
queryKey: ['skill', name],
|
||||
queryFn: () => fetchSkill(name!),
|
||||
enabled: !!name,
|
||||
})
|
||||
|
||||
const { data: processes } = useQuery({
|
||||
queryKey: ['supervisor'],
|
||||
queryFn: fetchSupervisorStatus,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ['logs', name],
|
||||
queryFn: () => fetchLogs(name!, 50),
|
||||
enabled: !!name,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const process = processes?.find(p => p.name === name)
|
||||
const status = process?.state === 'RUNNING' ? 'running' : 'stopped'
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!skill) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-400">Skill not found</p>
|
||||
<Link to="/skills" className="text-purple-400 hover:underline mt-2 inline-block">
|
||||
← Back to skills
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/skills"
|
||||
className="p-2 hover:bg-gray-800 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-400" />
|
||||
</Link>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-white">{skill.name}</h1>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<p className="text-gray-400">{skill.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'running' ? (
|
||||
<>
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors">
|
||||
<Square className="w-4 h-4" />
|
||||
Stop
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 text-gray-300 rounded-lg transition-colors">
|
||||
<RotateCw className="w-4 h-4" />
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="flex items-center gap-2 px-3 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors">
|
||||
<Play className="w-4 h-4" />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* README */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Documentation"
|
||||
action={<FileText className="w-4 h-4 text-gray-500" />}
|
||||
/>
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
<pre className="bg-gray-800 p-4 rounded-lg overflow-auto text-sm text-gray-300 whitespace-pre-wrap">
|
||||
{skill.readme || 'No documentation available'}
|
||||
</pre>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Logs */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Recent Logs"
|
||||
action={<Terminal className="w-4 h-4 text-gray-500" />}
|
||||
/>
|
||||
<div className="bg-gray-950 rounded-lg p-4 font-mono text-xs overflow-auto max-h-64">
|
||||
{logs?.length ? (
|
||||
logs.map((line, i) => (
|
||||
<div key={i} className="text-gray-400 hover:text-gray-200">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No logs available</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Info */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Information"
|
||||
action={<Settings className="w-4 h-4 text-gray-500" />}
|
||||
/>
|
||||
<dl className="space-y-3">
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Version</dt>
|
||||
<dd className="text-sm text-white">{skill.version}</dd>
|
||||
</div>
|
||||
{process && (
|
||||
<>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">PID</dt>
|
||||
<dd className="text-sm text-white">{process.pid || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Uptime</dt>
|
||||
<dd className="text-sm text-white">
|
||||
{process.uptime ? formatDuration(process.uptime) : '-'}
|
||||
</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{skill.metricsPort && (
|
||||
<div>
|
||||
<dt className="text-xs text-gray-500">Metrics Port</dt>
|
||||
<dd className="text-sm text-white">{skill.metricsPort}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Dependencies */}
|
||||
{skill.requires.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="Dependencies" />
|
||||
<div className="space-y-2">
|
||||
{skill.requires.map((dep) => (
|
||||
<Link
|
||||
key={dep}
|
||||
to={`/skills/${dep}`}
|
||||
className="flex items-center justify-between p-2 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-white">{dep}</span>
|
||||
<StatusBadge
|
||||
status={processes?.find(p => p.name === dep)?.state === 'RUNNING' ? 'running' : 'stopped'}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Configuration */}
|
||||
{skill.config && Object.keys(skill.config).length > 0 && (
|
||||
<Card>
|
||||
<CardHeader title="Configuration" />
|
||||
<dl className="space-y-2">
|
||||
{Object.entries(skill.config).map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<dt className="text-gray-500 font-mono text-xs">{key}</dt>
|
||||
<dd className="text-white truncate">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`
|
||||
}
|
||||
165
ui/src/pages/Skills.tsx
Normal file
165
ui/src/pages/Skills.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Play, Square, RotateCw, ExternalLink, Plus } from 'lucide-react'
|
||||
import { Card } from '../components/Card'
|
||||
import { StatusBadge } from '../components/StatusBadge'
|
||||
import { fetchSkills, fetchSupervisorStatus, startProcess, stopProcess, restartProcess } from '../lib/api'
|
||||
|
||||
export function Skills() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: skills, isLoading } = useQuery({
|
||||
queryKey: ['skills'],
|
||||
queryFn: fetchSkills,
|
||||
})
|
||||
|
||||
const { data: processes } = useQuery({
|
||||
queryKey: ['supervisor'],
|
||||
queryFn: fetchSupervisorStatus,
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: startProcess,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['supervisor'] }),
|
||||
})
|
||||
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: stopProcess,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['supervisor'] }),
|
||||
})
|
||||
|
||||
const restartMutation = useMutation({
|
||||
mutationFn: restartProcess,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['supervisor'] }),
|
||||
})
|
||||
|
||||
const getProcessStatus = (name: string) => {
|
||||
const process = processes?.find(p => p.name === name)
|
||||
if (!process) return 'stopped'
|
||||
return process.state === 'RUNNING' ? 'running' : 'stopped'
|
||||
}
|
||||
|
||||
const getProcessUptime = (name: string) => {
|
||||
const process = processes?.find(p => p.name === name)
|
||||
return process?.uptime ?? 0
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">Skills</h1>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Skill
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{skills?.map((skill) => {
|
||||
const status = getProcessStatus(skill.name)
|
||||
const uptime = getProcessUptime(skill.name)
|
||||
const isLoading = startMutation.isPending || stopMutation.isPending || restartMutation.isPending
|
||||
|
||||
return (
|
||||
<Card key={skill.name} className="hover:border-gray-700 transition-colors">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<Link
|
||||
to={`/skills/${skill.name}`}
|
||||
className="text-lg font-semibold text-white hover:text-purple-400 transition-colors"
|
||||
>
|
||||
{skill.name}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">v{skill.version}</p>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4 line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{skill.requires.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 mb-1">Requires:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.requires.map((dep) => (
|
||||
<span
|
||||
key={dep}
|
||||
className="px-2 py-0.5 bg-gray-800 text-gray-400 text-xs rounded"
|
||||
>
|
||||
{dep}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'running' && uptime > 0 && (
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Uptime: {formatDuration(uptime)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-gray-800">
|
||||
{status === 'running' ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => stopMutation.mutate(skill.name)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Square className="w-3 h-3" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => restartMutation.mutate(skill.name)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-yellow-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCw className="w-3 h-3" />
|
||||
Restart
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => startMutation.mutate(skill.name)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-green-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to={`/skills/${skill.name}`}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-gray-400 hover:text-white transition-colors ml-auto"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Details
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
|
||||
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`
|
||||
}
|
||||
7
ui/src/pages/index.ts
Normal file
7
ui/src/pages/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { Overview } from './Overview'
|
||||
export { Skills } from './Skills'
|
||||
export { SkillDetail } from './SkillDetail'
|
||||
export { Memory } from './Memory'
|
||||
export { Backups } from './Backups'
|
||||
export { Logs } from './Logs'
|
||||
export { Settings } from './Settings'
|
||||
11
ui/tailwind.config.js
Normal file
11
ui/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
ui/tsconfig.json
Normal file
25
ui/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
ui/tsconfig.node.json
Normal file
10
ui/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
ui/vite.config.ts
Normal file
14
ui/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user