Initial fleet skill
This commit is contained in:
34
SKILL.md
Normal file
34
SKILL.md
Normal 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
30
scripts/autorun.sh
Normal 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
22
scripts/run.sh
Normal 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
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>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
29
ui/package.json
Normal 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
6
ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
15
ui/src/App.tsx
Normal file
15
ui/src/App.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
ui/src/components/MachineCard.tsx
Normal file
75
ui/src/components/MachineCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
106
ui/src/components/MachineFrame.tsx
Normal file
106
ui/src/components/MachineFrame.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
83
ui/src/components/Sidebar.tsx
Normal file
83
ui/src/components/Sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
33
ui/src/components/StatusBadge.tsx
Normal file
33
ui/src/components/StatusBadge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
ui/src/components/index.ts
Normal file
5
ui/src/components/index.ts
Normal 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
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;
|
||||||
|
}
|
||||||
47
ui/src/lib/api.ts
Normal file
47
ui/src/lib/api.ts
Normal 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
27
ui/src/main.tsx
Normal 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
44
ui/src/pages/Machine.tsx
Normal 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
122
ui/src/pages/Overview.tsx
Normal 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
2
ui/src/pages/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Overview } from './Overview'
|
||||||
|
export { Machine } from './Machine'
|
||||||
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: process.env.FLEET_API_URL || 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user