Initial dashboard skill - Vite+React+Tailwind web UI

This commit is contained in:
Azat
2026-02-02 23:57:37 +01:00
commit 65ec6ad1ec
27 changed files with 2264 additions and 0 deletions

130
SKILL.md Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

25
ui/src/App.tsx Normal file
View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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`
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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,
},
},
},
})