Initial fleet skill

This commit is contained in:
Azat
2026-02-03 00:46:40 +01:00
commit bc2f2e89ed
23 changed files with 777 additions and 0 deletions

34
SKILL.md Normal file
View File

@@ -0,0 +1,34 @@
---
name: fleet
description: Multi-machine dashboard for VibeStack fleet management
metadata:
version: "1.0.0"
vibestack:
main: false
requires:
- fleet-api
---
# Fleet Dashboard
Web UI for managing multiple VibeStack machines. Displays registered machines in a sidebar and shows their dashboards in iframes.
## Features
- Machine list with status indicators
- Iframe embedding for machine dashboards
- Real-time status updates (polling every 10s)
- Overview page with machine cards
## Configuration
- `FLEET_UI_PORT` - UI server port (default: 3000)
- `FLEET_API_URL` - Fleet API URL (default: http://localhost:3001)
## Usage
1. Deploy fleet-api on the host machine
2. Deploy fleet UI on the host machine
3. Deploy fleet-client on each worker machine
4. Workers register with fleet-api
5. View all machines in the fleet dashboard

30
scripts/autorun.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/bin/bash
set -e
SKILL_DIR="$(dirname "$(dirname "$0")")"
UI_DIR="$SKILL_DIR/ui"
echo "=== Fleet Dashboard Setup ==="
# Check for Node.js
if ! command -v node &>/dev/null; then
echo "Installing Node.js..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
fi
echo "Node.js version: $(node --version)"
echo "npm version: $(npm --version)"
# Install UI dependencies
cd "$UI_DIR"
if [ ! -d "node_modules" ]; then
echo "Installing UI dependencies..."
npm install
fi
# Build UI
echo "Building UI..."
npm run build
echo "Fleet Dashboard setup complete"

22
scripts/run.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
FLEET_UI_PORT="${FLEET_UI_PORT:-3000}"
SKILL_DIR="$(dirname "$(dirname "$0")")"
UI_DIR="$SKILL_DIR/ui"
export FLEET_UI_PORT
export FLEET_API_URL="${FLEET_API_URL:-http://localhost:3001}"
echo "Starting Fleet Dashboard on port $FLEET_UI_PORT..."
echo "Fleet API: $FLEET_API_URL"
cd "$UI_DIR"
if [ "${NODE_ENV:-production}" = "development" ]; then
echo "Running in development mode..."
exec npm run dev -- --port "$FLEET_UI_PORT" --host
else
echo "Running in production mode..."
exec npm run preview -- --port "$FLEET_UI_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>Fleet 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-fleet",
"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: {},
},
}

15
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { Routes, Route } from 'react-router-dom'
import { Layout } from './components/Layout'
import { Overview } from './pages/Overview'
import { Machine } from './pages/Machine'
export default function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Overview />} />
<Route path="machine/:id" element={<Machine />} />
</Route>
</Routes>
)
}

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">
<Outlet />
</main>
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { Link } from 'react-router-dom'
import { Server, Clock, Blocks } from 'lucide-react'
import { clsx } from 'clsx'
import { Machine, timeSince } from '../lib/api'
import { StatusBadge } from './StatusBadge'
interface MachineCardProps {
machine: Machine
}
export function MachineCard({ machine }: MachineCardProps) {
return (
<Link
to={`/machine/${machine.id}`}
className="block bg-gray-900 border border-gray-800 rounded-xl p-4 hover:border-gray-700 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={clsx(
'w-10 h-10 rounded-lg flex items-center justify-center',
machine.status === 'running' && 'bg-green-500/20',
machine.status === 'stopped' && 'bg-gray-500/20',
machine.status === 'unknown' && 'bg-yellow-500/20'
)}>
<Server className={clsx(
'w-5 h-5',
machine.status === 'running' && 'text-green-400',
machine.status === 'stopped' && 'text-gray-400',
machine.status === 'unknown' && 'text-yellow-400'
)} />
</div>
<div>
<h3 className="font-semibold text-white">{machine.name}</h3>
<p className="text-xs text-gray-500">{machine.id.slice(0, 8)}...</p>
</div>
</div>
<StatusBadge status={machine.status} />
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-gray-400">
<Clock className="w-4 h-4" />
<span>Last seen: {timeSince(machine.lastSeen)}</span>
</div>
<div className="flex items-center gap-2 text-gray-400">
<Blocks className="w-4 h-4" />
<span>{machine.skills.length} skills</span>
</div>
{machine.skills.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{machine.skills.slice(0, 4).map((skill) => (
<span
key={skill}
className="px-2 py-0.5 bg-gray-800 rounded text-xs text-gray-400"
>
{skill}
</span>
))}
{machine.skills.length > 4 && (
<span className="px-2 py-0.5 text-xs text-gray-500">
+{machine.skills.length - 4} more
</span>
)}
</div>
)}
</div>
<div className="mt-3 pt-3 border-t border-gray-800 text-xs text-gray-500 truncate">
{machine.url}
</div>
</Link>
)
}

View File

@@ -0,0 +1,106 @@
import { useState } from 'react'
import { ExternalLink, RefreshCw, AlertCircle } from 'lucide-react'
import { Machine } from '../lib/api'
import { StatusBadge } from './StatusBadge'
interface MachineFrameProps {
machine: Machine
}
export function MachineFrame({ machine }: MachineFrameProps) {
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
const handleLoad = () => {
setIsLoading(false)
setHasError(false)
}
const handleError = () => {
setIsLoading(false)
setHasError(true)
}
const refresh = () => {
setIsLoading(true)
setHasError(false)
// Force iframe reload by updating key
const iframe = document.getElementById('machine-frame') as HTMLIFrameElement
if (iframe) {
iframe.src = machine.url
}
}
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 bg-gray-900 border-b border-gray-800">
<div className="flex items-center gap-3">
<h2 className="font-semibold text-white">{machine.name}</h2>
<StatusBadge status={machine.status} />
</div>
<div className="flex items-center gap-2">
<button
onClick={refresh}
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
title="Refresh"
>
<RefreshCw className="w-4 h-4" />
</button>
<a
href={machine.url}
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
title="Open in new tab"
>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
<div className="flex-1 relative bg-gray-950">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-950">
<div className="flex items-center gap-2 text-gray-400">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading dashboard...</span>
</div>
</div>
)}
{hasError && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-950">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
<p className="text-gray-400 mb-2">Failed to load dashboard</p>
<p className="text-sm text-gray-500 mb-4">{machine.url}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg text-sm font-medium transition-colors"
>
Try Again
</button>
</div>
</div>
)}
{machine.status === 'unknown' && (
<div className="absolute top-0 left-0 right-0 px-4 py-2 bg-yellow-500/10 border-b border-yellow-500/20 text-yellow-400 text-sm text-center">
Machine may be offline - last heartbeat was over 60 seconds ago
</div>
)}
<iframe
id="machine-frame"
src={machine.url}
className="w-full h-full border-0"
onLoad={handleLoad}
onError={handleError}
title={`${machine.name} dashboard`}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,83 @@
import { NavLink } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { LayoutDashboard, Server, Circle } from 'lucide-react'
import { clsx } from 'clsx'
import { fetchMachines, Machine } from '../lib/api'
const statusColors: Record<Machine['status'], string> = {
running: 'text-green-400',
stopped: 'text-gray-400',
unknown: 'text-yellow-400',
}
export function Sidebar() {
const { data: machines = [] } = useQuery({
queryKey: ['machines'],
queryFn: fetchMachines,
})
return (
<aside className="w-64 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-blue-500 to-cyan-500 rounded-lg flex items-center justify-center">
<Server className="w-5 h-5 text-white" />
</div>
Fleet
</h1>
</div>
<nav className="flex-1 p-3 space-y-1 overflow-auto">
<NavLink
to="/"
end
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'
)
}
>
<LayoutDashboard className="w-5 h-5" />
Overview
</NavLink>
<div className="pt-4 pb-2 px-3">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Machines ({machines.length})
</span>
</div>
{machines.map((machine) => (
<NavLink
key={machine.id}
to={`/machine/${machine.id}`}
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'
)
}
>
<Circle className={clsx('w-2 h-2 fill-current', statusColors[machine.status])} />
<span className="truncate">{machine.name}</span>
</NavLink>
))}
{machines.length === 0 && (
<div className="px-3 py-4 text-sm text-gray-500 text-center">
No machines registered
</div>
)}
</nav>
<div className="p-3 border-t border-gray-800 text-xs text-gray-500">
Polling every 10s
</div>
</aside>
)
}

View File

@@ -0,0 +1,33 @@
import { clsx } from 'clsx'
type Status = 'running' | 'stopped' | 'unknown'
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',
unknown: '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 === 'unknown' && 'bg-yellow-400 animate-pulse'
)} />
{label || status}
</span>
)
}

View File

@@ -0,0 +1,5 @@
export { Layout } from './Layout'
export { Sidebar } from './Sidebar'
export { StatusBadge } from './StatusBadge'
export { MachineCard } from './MachineCard'
export { MachineFrame } from './MachineFrame'

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;
}

47
ui/src/lib/api.ts Normal file
View File

@@ -0,0 +1,47 @@
const API_BASE = '/api'
export interface Machine {
id: string
name: string
url: string
version: string
skills: string[]
status: 'running' | 'stopped' | 'unknown'
lastSeen: number
}
export async function fetchMachines(): Promise<Machine[]> {
const res = await fetch(`${API_BASE}/machines`)
if (!res.ok) throw new Error('Failed to fetch machines')
return res.json()
}
export async function fetchMachine(id: string): Promise<Machine> {
const res = await fetch(`${API_BASE}/machines/${id}`)
if (!res.ok) throw new Error('Failed to fetch machine')
return res.json()
}
export async function deleteMachine(id: string): Promise<void> {
const res = await fetch(`${API_BASE}/machines/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Failed to delete machine')
}
export async function registerMachine(machine: Omit<Machine, 'lastSeen'>): Promise<Machine> {
const res = await fetch(`${API_BASE}/machines/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(machine),
})
if (!res.ok) throw new Error('Failed to register machine')
return res.json()
}
// Calculate time since last seen
export function timeSince(timestamp: number): string {
const seconds = Math.floor(Date.now() / 1000 - timestamp)
if (seconds < 60) return `${seconds}s ago`
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`
}

27
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,27 @@
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: 5000,
refetchInterval: 10000, // Poll every 10s for machine updates
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

44
ui/src/pages/Machine.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { useParams, Navigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { fetchMachine } from '../lib/api'
import { MachineFrame } from '../components/MachineFrame'
export function Machine() {
const { id } = useParams<{ id: string }>()
const { data: machine, isLoading, error } = useQuery({
queryKey: ['machine', id],
queryFn: () => fetchMachine(id!),
enabled: !!id,
})
if (!id) {
return <Navigate to="/" replace />
}
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-2 text-gray-400">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Loading machine...</span>
</div>
</div>
)
}
if (error || !machine) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
<p className="text-gray-400 mb-2">Machine not found</p>
<p className="text-sm text-gray-500">ID: {id}</p>
</div>
</div>
)
}
return <MachineFrame machine={machine} />
}

122
ui/src/pages/Overview.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useQuery } from '@tanstack/react-query'
import { Server, CheckCircle, AlertCircle, HelpCircle } from 'lucide-react'
import { fetchMachines, Machine } from '../lib/api'
import { MachineCard } from '../components/MachineCard'
export function Overview() {
const { data: machines = [], isLoading, error } = useQuery({
queryKey: ['machines'],
queryFn: fetchMachines,
})
const stats = {
total: machines.length,
running: machines.filter((m) => m.status === 'running').length,
stopped: machines.filter((m) => m.status === 'stopped').length,
unknown: machines.filter((m) => m.status === 'unknown').length,
}
if (error) {
return (
<div className="p-6">
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-6 text-center">
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-3" />
<p className="text-red-400">Failed to load machines</p>
<p className="text-sm text-gray-500 mt-2">Make sure fleet-api is running</p>
</div>
</div>
)
}
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Fleet Overview</h1>
<p className="text-gray-400 mt-1">Manage your VibeStack machines</p>
</div>
<div className="grid grid-cols-4 gap-4">
<StatCard
icon={Server}
label="Total Machines"
value={stats.total}
color="blue"
/>
<StatCard
icon={CheckCircle}
label="Running"
value={stats.running}
color="green"
/>
<StatCard
icon={AlertCircle}
label="Stopped"
value={stats.stopped}
color="gray"
/>
<StatCard
icon={HelpCircle}
label="Unknown"
value={stats.unknown}
color="yellow"
/>
</div>
{isLoading ? (
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-xl p-4 animate-pulse">
<div className="h-12 bg-gray-800 rounded mb-3" />
<div className="h-4 bg-gray-800 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-800 rounded w-1/2" />
</div>
))}
</div>
) : machines.length === 0 ? (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-12 text-center">
<Server className="w-16 h-16 text-gray-700 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No machines registered</h3>
<p className="text-gray-400 max-w-md mx-auto">
Deploy fleet-client on your machines to register them with this fleet dashboard.
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-4">
{machines.map((machine) => (
<MachineCard key={machine.id} machine={machine} />
))}
</div>
)}
</div>
)
}
interface StatCardProps {
icon: React.ElementType
label: string
value: number
color: 'blue' | 'green' | 'gray' | 'yellow'
}
const colorStyles = {
blue: 'bg-blue-500/20 text-blue-400',
green: 'bg-green-500/20 text-green-400',
gray: 'bg-gray-500/20 text-gray-400',
yellow: 'bg-yellow-500/20 text-yellow-400',
}
function StatCard({ icon: Icon, label, value, color }: StatCardProps) {
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${colorStyles[color]}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className="text-2xl font-bold text-white">{value}</p>
<p className="text-sm text-gray-400">{label}</p>
</div>
</div>
</div>
)
}

2
ui/src/pages/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { Overview } from './Overview'
export { Machine } from './Machine'

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: process.env.FLEET_API_URL || 'http://localhost:3001',
changeOrigin: true,
},
},
},
})