Initial dashboard skill - Vite+React+Tailwind web UI
This commit is contained in:
130
SKILL.md
Normal file
130
SKILL.md
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
---
|
||||||
|
name: dashboard
|
||||||
|
description: Web dashboard for managing skills, memory, and system status
|
||||||
|
metadata:
|
||||||
|
version: "1.0.0"
|
||||||
|
vibestack:
|
||||||
|
main: false
|
||||||
|
requires:
|
||||||
|
- caddy
|
||||||
|
- supervisor
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dashboard Skill
|
||||||
|
|
||||||
|
Web-based control panel for the VibeStack agent machine.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Skills Overview**: View all installed skills, their status, and capabilities
|
||||||
|
- **Memory Browser**: Search and browse agent memory (conversations, findings, etc.)
|
||||||
|
- **Backup Management**: View backup status, trigger manual backups, restore
|
||||||
|
- **Logs Viewer**: Real-time log tailing with filtering
|
||||||
|
- **System Stats**: Disk, memory, CPU usage
|
||||||
|
- **Skill Discovery**: Browse and install new skills (via skill-downloader)
|
||||||
|
|
||||||
|
## Awareness
|
||||||
|
|
||||||
|
This dashboard is aware of:
|
||||||
|
|
||||||
|
### Caddy Configuration
|
||||||
|
- Reads `/skills/caddy/Caddyfile` and `snippets.d/*.caddy`
|
||||||
|
- Shows which domains/routes are configured
|
||||||
|
- Displays TLS certificate status
|
||||||
|
|
||||||
|
### Installed Skills
|
||||||
|
- Scans `/skills/*/SKILL.md` for all installed skills
|
||||||
|
- Parses YAML frontmatter for metadata (name, version, requires, etc.)
|
||||||
|
- Shows skill dependencies and relationships
|
||||||
|
- Reads skill documentation for capabilities
|
||||||
|
|
||||||
|
### Extensibility
|
||||||
|
- **skill-downloader** (planned): Install new skills from git repositories
|
||||||
|
- **skill-creator** (planned): Create new skills from templates or AI generation
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `DASHBOARD_PORT` | `3000` | Dashboard port |
|
||||||
|
| `DASHBOARD_DOMAIN` | (none) | Domain for Caddy auto-config |
|
||||||
|
| `SUPERVISOR_URL` | `http://localhost:9001` | Supervisor XML-RPC URL |
|
||||||
|
| `MEMORY_URL` | `http://localhost:8081` | Memory API URL |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Vite** - Build tool
|
||||||
|
- **React 18** - UI framework
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **Tailwind CSS** - Styling
|
||||||
|
- **Lucide React** - Icons
|
||||||
|
- **React Query** - Data fetching
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The dashboard backend provides:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/skills # List all skills with metadata
|
||||||
|
GET /api/skills/:name # Get skill details
|
||||||
|
GET /api/supervisor/status # Process status from supervisor
|
||||||
|
POST /api/supervisor/:name/restart # Restart a process
|
||||||
|
GET /api/caddy/config # Caddy configuration
|
||||||
|
GET /api/system/stats # System resource usage
|
||||||
|
GET /api/logs/:skill # Tail logs for a skill
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/` | Overview dashboard with quick stats |
|
||||||
|
| `/skills` | All skills with status and controls |
|
||||||
|
| `/skills/:name` | Skill detail with docs and logs |
|
||||||
|
| `/memory` | Memory browser with search |
|
||||||
|
| `/backups` | Backup status and controls |
|
||||||
|
| `/logs` | Multi-skill log viewer |
|
||||||
|
| `/settings` | Caddy config, system settings |
|
||||||
|
|
||||||
|
## Caddy Integration
|
||||||
|
|
||||||
|
If `DASHBOARD_DOMAIN` is set, auto-registers with Caddy:
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
dashboard.example.com {
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start in dev mode
|
||||||
|
cd /skills/dashboard/ui
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Build for production
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ VibeStack Dashboard [user] [⚙] │
|
||||||
|
├─────────┬───────────────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
│ Overview│ Skills: 8 running, 1 stopped │
|
||||||
|
│ Skills │ Memory: 1,234 entries │
|
||||||
|
│ Memory │ Last Backup: 2h ago ✓ │
|
||||||
|
│ Backups │ Disk: 45% used │
|
||||||
|
│ Logs │ │
|
||||||
|
│ Settings│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||||
|
│ │ │ postgres│ │ redis │ │ caddy │ │
|
||||||
|
│ │ │ ● run │ │ ● run │ │ ● run │ │
|
||||||
|
│ │ └─────────┘ └─────────┘ └─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
└─────────┴───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
101
scripts/autorun.sh
Normal file
101
scripts/autorun.sh
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
NODE_VERSION="${NODE_VERSION:-20}"
|
||||||
|
SKILLS_DIR="${SKILLS_DIR:-/skills}"
|
||||||
|
SKILL_DIR="$(dirname "$(dirname "$0")")"
|
||||||
|
|
||||||
|
# Idempotent Node.js installation
|
||||||
|
install_node() {
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
echo "Node.js already installed: $(node --version)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing Node.js ${NODE_VERSION}..."
|
||||||
|
|
||||||
|
# Install via NodeSource
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y curl ca-certificates gnupg
|
||||||
|
|
||||||
|
mkdir -p /etc/apt/keyrings
|
||||||
|
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
|
||||||
|
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
|
||||||
|
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y nodejs
|
||||||
|
|
||||||
|
echo "Node.js installed: $(node --version)"
|
||||||
|
echo "npm installed: $(npm --version)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install UI dependencies
|
||||||
|
install_deps() {
|
||||||
|
local ui_dir="$SKILL_DIR/ui"
|
||||||
|
|
||||||
|
if [ -d "$ui_dir/node_modules" ]; then
|
||||||
|
echo "Dependencies already installed"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing UI dependencies..."
|
||||||
|
cd "$ui_dir"
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo "Dependencies installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build UI for production
|
||||||
|
build_ui() {
|
||||||
|
local ui_dir="$SKILL_DIR/ui"
|
||||||
|
|
||||||
|
if [ -d "$ui_dir/dist" ]; then
|
||||||
|
echo "UI already built"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building UI..."
|
||||||
|
cd "$ui_dir"
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "UI built"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Configure Caddy if domain set
|
||||||
|
configure_caddy() {
|
||||||
|
local caddy_dir="$SKILLS_DIR/caddy"
|
||||||
|
local dashboard_domain="${DASHBOARD_DOMAIN:-}"
|
||||||
|
local dashboard_port="${DASHBOARD_PORT:-3000}"
|
||||||
|
|
||||||
|
if [ ! -d "$caddy_dir" ]; then
|
||||||
|
echo "Caddy not found - dashboard will run standalone"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$dashboard_domain" ]; then
|
||||||
|
echo "DASHBOARD_DOMAIN not set - skipping Caddy config"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Configuring Caddy for $dashboard_domain..."
|
||||||
|
|
||||||
|
local snippets_dir="$caddy_dir/snippets.d"
|
||||||
|
mkdir -p "$snippets_dir"
|
||||||
|
|
||||||
|
cat > "$snippets_dir/dashboard.caddy" << EOF
|
||||||
|
# Auto-generated by dashboard skill
|
||||||
|
$dashboard_domain {
|
||||||
|
reverse_proxy localhost:$dashboard_port
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Caddy config: $dashboard_domain -> localhost:$dashboard_port"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_node
|
||||||
|
install_deps
|
||||||
|
build_ui
|
||||||
|
configure_caddy
|
||||||
|
|
||||||
|
echo "Dashboard setup complete"
|
||||||
28
scripts/run.sh
Normal file
28
scripts/run.sh
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DASHBOARD_PORT="${DASHBOARD_PORT:-3000}"
|
||||||
|
SKILL_DIR="$(dirname "$(dirname "$0")")"
|
||||||
|
UI_DIR="$SKILL_DIR/ui"
|
||||||
|
|
||||||
|
# Export environment for the app
|
||||||
|
export DASHBOARD_PORT
|
||||||
|
export SUPERVISOR_URL="${SUPERVISOR_URL:-http://localhost:9001}"
|
||||||
|
export MEMORY_URL="${MEMORY_URL:-http://localhost:8081}"
|
||||||
|
export SKILLS_DIR="${SKILLS_DIR:-/skills}"
|
||||||
|
|
||||||
|
echo "Starting Dashboard on port $DASHBOARD_PORT..."
|
||||||
|
echo "Supervisor: $SUPERVISOR_URL"
|
||||||
|
echo "Memory API: $MEMORY_URL"
|
||||||
|
|
||||||
|
cd "$UI_DIR"
|
||||||
|
|
||||||
|
# In production, serve built files
|
||||||
|
# In development, run vite dev server
|
||||||
|
if [ "${NODE_ENV:-production}" = "development" ]; then
|
||||||
|
echo "Running in development mode..."
|
||||||
|
exec npm run dev -- --port "$DASHBOARD_PORT" --host
|
||||||
|
else
|
||||||
|
echo "Running in production mode..."
|
||||||
|
exec npm run preview -- --port "$DASHBOARD_PORT" --host
|
||||||
|
fi
|
||||||
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