diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..ebbf0f13 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +* text=auto eol=lf + +*.sh text eol=lf +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary diff --git a/dev.ps1 b/dev.ps1 index 62b8a9dd..c9ff27d5 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -3,19 +3,25 @@ .SYNOPSIS ServerKit development launcher and validation tool. .DESCRIPTION - Start backend, frontend, or both. Run validation checks. + Start backend, frontend, both, or expose backend via an ngrok tunnel. + Run validation checks. .PARAMETER Mode - Operation mode: start (default), backend, frontend, validate + Operation mode: start (default), backend, frontend, tunnel, validate .EXAMPLE .\dev.ps1 # Start backend + frontend .\dev.ps1 backend # Backend only .\dev.ps1 frontend # Frontend only + .\dev.ps1 tunnel # Backend + frontend + ngrok tunnel for the backend .\dev.ps1 validate # Run all linters/checks +.NOTES + Optional environment variables for tunnel mode: + NGROK_DOMAIN - reserved ngrok domain (e.g. my-app.ngrok-free.app) + NGROK_AUTHTOKEN - ngrok authtoken (alternatively run `ngrok config add-authtoken`) #> param( [Parameter(Position = 0)] - [ValidateSet('start', 'backend', 'frontend', 'validate')] + [ValidateSet('start', 'backend', 'frontend', 'tunnel', 'validate')] [string]$Mode = 'start' ) @@ -120,6 +126,120 @@ function Start-Both { } } +function Start-Tunnel { + if (-not (Get-Command ngrok -ErrorAction SilentlyContinue)) { + Write-Host "Error: ngrok is not installed or not in PATH." -ForegroundColor Red + Write-Host "" + Write-Host "Install ngrok:" + Write-Host " - https://ngrok.com/download" + Write-Host " - Windows: choco install ngrok (or scoop install ngrok)" + Write-Host " - WSL: snap install ngrok (or download the .tgz)" + Write-Host "" + Write-Host "Then authenticate once: ngrok config add-authtoken " + exit 1 + } + + Write-Host "" + Write-Host "ServerKit Dev Server (with ngrok tunnel)" -ForegroundColor Cyan + Write-Host " Backend: http://localhost:5000" + Write-Host " Frontend: http://localhost:5173" + Write-Host " Tunnel: exposing backend (port 5000) via ngrok" + Write-Host "" + Write-Host "NOTE: " -ForegroundColor Yellow -NoNewline + Write-Host "Agents and remote callers should use the public ngrok URL" + Write-Host " printed below as their --server / control plane URL." + Write-Host "" + + if (-not $env:CORS_ORIGINS) { + $env:CORS_ORIGINS = 'http://localhost:5173,http://localhost:5000,https://*.ngrok-free.app,https://*.ngrok.app,https://*.ngrok.io' + } + + $envSnapshot = @{ + CORS_ORIGINS = $env:CORS_ORIGINS + } + + $backendJob = Start-Job -ScriptBlock { + param($dir, $envVars) + foreach ($k in $envVars.Keys) { Set-Item -Path "Env:$k" -Value $envVars[$k] } + Set-Location $dir + if (Test-Path 'venv\Scripts\Activate.ps1') { + & 'venv\Scripts\Activate.ps1' + } + python run.py + } -ArgumentList $BackendDir, $envSnapshot + + Start-Sleep -Seconds 2 + + $frontendJob = Start-Job -ScriptBlock { + param($dir) + Set-Location $dir + npm run dev + } -ArgumentList $FrontendDir + + Start-Sleep -Seconds 1 + + $ngrokArgs = @('http', '5000', '--log=stdout') + if ($env:NGROK_DOMAIN) { $ngrokArgs += "--domain=$($env:NGROK_DOMAIN)" } + if ($env:NGROK_AUTHTOKEN) { $ngrokArgs += "--authtoken=$($env:NGROK_AUTHTOKEN)" } + + $ngrokProc = Start-Process -FilePath 'ngrok' -ArgumentList $ngrokArgs -PassThru -WindowStyle Hidden + + $urlPrinted = $false + try { + Write-Host "Press Ctrl+C to stop..." -ForegroundColor DarkGray + $polls = 0 + while ($true) { + Receive-Job $backendJob -ErrorAction SilentlyContinue + Receive-Job $frontendJob -ErrorAction SilentlyContinue + + if (-not $urlPrinted -and $polls -lt 30) { + try { + $resp = Invoke-RestMethod -Uri 'http://127.0.0.1:4040/api/tunnels' -TimeoutSec 1 -ErrorAction Stop + $publicUrl = ($resp.tunnels | Where-Object { $_.public_url -like 'https://*' } | Select-Object -First 1).public_url + if ($publicUrl) { + Write-Host "" + Write-Host "=========================================================" -ForegroundColor Green + Write-Host " Public tunnel URL: " -ForegroundColor Green -NoNewline + Write-Host $publicUrl -ForegroundColor Cyan + Write-Host " Use this as your agent --server / control plane URL." -ForegroundColor Green + Write-Host "=========================================================" -ForegroundColor Green + Write-Host "" + $urlPrinted = $true + } + } + catch { } + $polls++ + } + + if ($backendJob.State -eq 'Failed') { + Write-Host "Backend crashed!" -ForegroundColor Red + Receive-Job $backendJob + break + } + if ($frontendJob.State -eq 'Failed') { + Write-Host "Frontend crashed!" -ForegroundColor Red + Receive-Job $frontendJob + break + } + if ($ngrokProc.HasExited) { + Write-Host "ngrok exited." -ForegroundColor Red + break + } + Start-Sleep -Seconds 1 + } + } + finally { + Stop-Job $backendJob -ErrorAction SilentlyContinue + Stop-Job $frontendJob -ErrorAction SilentlyContinue + Remove-Job $backendJob -Force -ErrorAction SilentlyContinue + Remove-Job $frontendJob -Force -ErrorAction SilentlyContinue + if ($ngrokProc -and -not $ngrokProc.HasExited) { + Stop-Process -Id $ngrokProc.Id -ErrorAction SilentlyContinue + } + Write-Host "`nStopped." -ForegroundColor Yellow + } +} + function Invoke-Check { param( [string]$Name, @@ -234,6 +354,7 @@ function Run-ValidateWatch { switch ($Mode) { 'backend' { Start-Backend } 'frontend' { Start-Frontend } + 'tunnel' { Start-Tunnel } 'validate' { Run-ValidateWatch } default { Start-Both } } diff --git a/dev.sh b/dev.sh index 8028c0cf..cdee06d2 100644 --- a/dev.sh +++ b/dev.sh @@ -5,7 +5,12 @@ # ./dev.sh Start backend + frontend (default) # ./dev.sh backend Backend only # ./dev.sh frontend Frontend only +# ./dev.sh tunnel Start backend + frontend + expose backend via ngrok # ./dev.sh validate Run all linters/checks +# +# Environment variables: +# NGROK_DOMAIN Optional reserved ngrok domain (e.g. my-app.ngrok-free.app) +# NGROK_AUTHTOKEN Optional ngrok authtoken (alternatively run `ngrok config add-authtoken`) set -euo pipefail @@ -83,6 +88,94 @@ start_both() { wait } +start_tunnel() { + if ! command -v ngrok &>/dev/null; then + echo -e "${RED}Error:${NC} ngrok is not installed or not in PATH." + echo "" + echo "Install ngrok:" + echo " - https://ngrok.com/download" + echo " - macOS: brew install ngrok/ngrok/ngrok" + echo " - Linux: snap install ngrok (or download the .tgz)" + echo " - Windows: choco install ngrok (or scoop install ngrok)" + echo "" + echo "Then authenticate once: ngrok config add-authtoken " + exit 1 + fi + + echo "" + echo -e "${CYAN}ServerKit Dev Server (with ngrok tunnel)${NC}" + echo " Backend: http://localhost:5000" + echo " Frontend: http://localhost:5173" + echo " Tunnel: exposing backend (port 5000) via ngrok" + echo "" + echo -e "${YELLOW}NOTE:${NC} Agents and remote callers should use the public ngrok URL" + echo " printed below as their --server / control plane URL." + echo "" + + # Allow CORS from any ngrok domain by default unless caller already set it. + export CORS_ORIGINS="${CORS_ORIGINS:-http://localhost:5173,http://localhost:5000,https://*.ngrok-free.app,https://*.ngrok.app,https://*.ngrok.io}" + + cd "$BACKEND_DIR" + if [ -f venv/bin/activate ]; then + source venv/bin/activate + fi + python run.py & + BACKEND_PID=$! + + sleep 2 + + cd "$FRONTEND_DIR" + npm run dev & + FRONTEND_PID=$! + + sleep 1 + + NGROK_ARGS=(http 5000 --log=stdout) + if [ -n "${NGROK_DOMAIN:-}" ]; then + NGROK_ARGS+=("--domain=$NGROK_DOMAIN") + fi + if [ -n "${NGROK_AUTHTOKEN:-}" ]; then + NGROK_ARGS+=("--authtoken=$NGROK_AUTHTOKEN") + fi + + ngrok "${NGROK_ARGS[@]}" & + NGROK_PID=$! + + # Poll the local ngrok API for the public URL and print it prominently. + ( + for _ in $(seq 1 20); do + sleep 1 + if command -v curl &>/dev/null; then + URL=$(curl -fsS http://127.0.0.1:4040/api/tunnels 2>/dev/null \ + | grep -oE '"public_url":"https://[^"]+' \ + | head -n1 \ + | sed 's/"public_url":"//') + if [ -n "$URL" ]; then + echo "" + echo -e "${GREEN}=========================================================${NC}" + echo -e "${GREEN} Public tunnel URL: ${NC}${CYAN}$URL${NC}" + echo -e "${GREEN} Use this as your agent --server / control plane URL.${NC}" + echo -e "${GREEN}=========================================================${NC}" + echo "" + break + fi + fi + done + ) & + + cleanup_tunnel() { + echo "" + echo -e "${YELLOW}Stopping...${NC}" + kill "$BACKEND_PID" "$FRONTEND_PID" "$NGROK_PID" 2>/dev/null || true + wait "$BACKEND_PID" "$FRONTEND_PID" "$NGROK_PID" 2>/dev/null || true + echo "Stopped." + } + trap cleanup_tunnel INT TERM + + echo -e "${DIM}Press Ctrl+C to stop...${NC}" + wait +} + run_validate() { header "ServerKit Validation Suite" local failed=0 @@ -173,6 +266,7 @@ MODE="${1:-start}" case "$MODE" in backend) start_backend ;; frontend) start_frontend ;; + tunnel) start_tunnel ;; validate) run_validate_watch ;; start|*) start_both ;; esac diff --git a/docs/LOCAL_DEVELOPMENT.md b/docs/LOCAL_DEVELOPMENT.md index a0175936..76056b6d 100644 --- a/docs/LOCAL_DEVELOPMENT.md +++ b/docs/LOCAL_DEVELOPMENT.md @@ -252,3 +252,90 @@ sudo usermod -aG docker $USER # Remove the lock file rm backend/instance/serverkit.db-journal ``` + +--- + +## Exposing Your Local Control Plane (ngrok) + +When developing locally on Windows/WSL you may want a remote ServerKit agent (or +a Docker host on another machine) to connect back to your machine. The `dev.sh` +and `dev.ps1` scripts include a `tunnel` mode that starts the backend, frontend, +and an ngrok tunnel pointed at the backend (port 5000). + +### Prerequisites + +1. Install ngrok and authenticate once: + + ```bash + # macOS / Linux / WSL + brew install ngrok/ngrok/ngrok # or: snap install ngrok + ngrok config add-authtoken + ``` + + ```powershell + # Windows + choco install ngrok # or: scoop install ngrok + ngrok config add-authtoken + ``` + +2. (Optional) Reserve a domain in the ngrok dashboard so the URL stays stable + across restarts. + +### Start with a tunnel + +```bash +# Linux / macOS / WSL +./dev.sh tunnel +``` + +```powershell +# Windows +.\dev.ps1 tunnel +``` + +The public ngrok URL is printed in green once the tunnel is up. Use it as the +`--server` argument when registering an agent: + +```bash +serverkit-agent register \ + --token "sk_reg_xxx" \ + --server "https://.ngrok-free.app" +``` + +### Optional environment variables + +| Variable | Purpose | +|----------|---------| +| `NGROK_DOMAIN` | Use a reserved ngrok domain (e.g. `my-app.ngrok-free.app`). | +| `NGROK_AUTHTOKEN` | Override the authtoken (otherwise the one stored by `ngrok config` is used). | +| `CORS_ORIGINS` | Override CORS allow-list. The script defaults include `https://*.ngrok-free.app`, `https://*.ngrok.app`, `https://*.ngrok.io`. | + +--- + +## Quick Start with WSL2 (Helper Script) + +If you're on Windows and using WSL2 Ubuntu/Debian, you can skip the manual +steps above and use the bundled setup helper: + +```bash +# from inside WSL, in the cloned repo +bash scripts/setup-wsl.sh # full setup +bash scripts/setup-wsl.sh --check # dry-run / diagnostics only +``` + +The script will: + +- Detect WSL and warn if the repo lives under `/mnt/c` (slow file I/O for hot-reload). +- Verify Ubuntu/Debian and install missing apt packages + (`python3`, `python3-venv`, `python3-pip`, `nodejs`, `npm`, `git`, `curl`). +- Create `backend/venv` and install Python requirements. +- Run `npm install` in `frontend/`. +- Copy `backend/.env.example` to `backend/.env` if missing. +- Print WSL-specific tips (localhost forwarding, polling reloader, ngrok). + +Once it finishes: + +```bash +./dev.sh # start backend + frontend +./dev.sh tunnel # also expose backend via ngrok (see section above) +``` diff --git a/frontend/src/pages/ServerDetail.jsx b/frontend/src/pages/ServerDetail.jsx index 673d9595..bbb875e0 100644 --- a/frontend/src/pages/ServerDetail.jsx +++ b/frontend/src/pages/ServerDetail.jsx @@ -152,13 +152,6 @@ const ServerDetail = () => { ); } - const statusColors = { - online: '#10B981', - offline: '#EF4444', - connecting: '#F59E0B', - pending: '#6B7280' - }; - const tabs = [ { id: 'overview', label: 'Overview' }, { id: 'docker', label: 'Docker' }, @@ -168,35 +161,55 @@ const ServerDetail = () => { return (
-
-
- Servers - / - {server.name} -
-
-
-
-

{server.name}

+
+ Servers + / + {server.name} +
+ +
+
+
+ {(server.name || '?').charAt(0).toUpperCase()} +
+
+
+

{server.name}

+ + + {server.status || 'pending'} + +
+
+ {server.hostname || server.ip_address || 'No endpoint configured'} + {server.group_name && ( + {server.group_name} + )} + {server.os_type && {server.os_type}} + {server.agent_version && agent {server.agent_version}} + {server.last_seen && ( + + last seen {new Date(server.last_seen).toLocaleString()} + + )} +
+ {server.description && ( +

{server.description}

+ )}
-

- {server.hostname || server.ip_address} - {server.description && ` - ${server.description}`} -

-
+
+ + Docker +
-
+
{tabs.map(t => ( @@ -1181,6 +1194,12 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${token}"` : }; // Icons +const FolderTinyIcon = () => ( + + + +); + const RefreshIcon = () => ( diff --git a/frontend/src/pages/Servers.jsx b/frontend/src/pages/Servers.jsx index 2602a559..5ff585fb 100644 --- a/frontend/src/pages/Servers.jsx +++ b/frontend/src/pages/Servers.jsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import api from '../services/api'; import { useToast } from '../contexts/ToastContext'; +import { ConfirmDialog } from '../components/ConfirmDialog'; const Servers = () => { const [servers, setServers] = useState([]); @@ -10,14 +11,14 @@ const Servers = () => { const [showAddModal, setShowAddModal] = useState(false); const [showGroupModal, setShowGroupModal] = useState(false); const [selectedGroup, setSelectedGroup] = useState('all'); + const [selectedStatus, setSelectedStatus] = useState('all'); const [searchTerm, setSearchTerm] = useState(''); + const [selectedIds, setSelectedIds] = useState(() => new Set()); + const [deleteTarget, setDeleteTarget] = useState(null); + const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); const toast = useToast(); - useEffect(() => { - loadData(); - }, []); - - async function loadData() { + const loadData = useCallback(async () => { setLoading(true); try { const [serversData, groupsData] = await Promise.all([ @@ -32,7 +33,11 @@ const Servers = () => { } finally { setLoading(false); } - } + }, [toast]); + + useEffect(() => { + loadData(); + }, [loadData]); async function handlePingServer(serverId) { try { @@ -43,134 +48,309 @@ const Servers = () => { toast.error('Server did not respond'); } loadData(); - } catch (err) { + } catch { toast.error('Failed to ping server'); } } + async function handleDeleteServer() { + if (!deleteTarget) return; + try { + await api.deleteServer(deleteTarget.id); + toast.success(`${deleteTarget.name} removed from fleet`); + setSelectedIds(prev => { + const next = new Set(prev); + next.delete(deleteTarget.id); + return next; + }); + setDeleteTarget(null); + loadData(); + } catch (err) { + toast.error(err.message || 'Failed to delete server'); + } + } + + async function handleBulkDelete() { + const ids = Array.from(selectedIds); + if (ids.length === 0) return; + const results = await Promise.allSettled(ids.map(id => api.deleteServer(id))); + const failed = results.filter(r => r.status === 'rejected').length; + if (failed === 0) { + toast.success(`${ids.length} server${ids.length === 1 ? '' : 's'} deleted`); + } else { + toast.error(`${failed} of ${ids.length} could not be deleted`); + } + setSelectedIds(new Set()); + setBulkDeleteOpen(false); + loadData(); + } + + async function handleCopyInstall(server) { + try { + const result = await api.generateRegistrationToken(server.id); + const token = result?.registration_token || result?.token; + if (!token) { + toast.error('Could not generate install command'); + return; + } + const script = `curl -fsSL ${window.location.origin}/api/v1/servers/install.sh | sudo bash -s -- \\\n --server "${window.location.origin}" \\\n --token "${token}"`; + await navigator.clipboard.writeText(script); + toast.success('Install command copied to clipboard'); + } catch (err) { + toast.error(err.message || 'Failed to generate install command'); + } + } + + function toggleSelect(id) { + setSelectedIds(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + + function toggleSelectAll(visibleIds) { + setSelectedIds(prev => { + const allSelected = visibleIds.length > 0 && visibleIds.every(id => prev.has(id)); + if (allSelected) { + const next = new Set(prev); + visibleIds.forEach(id => next.delete(id)); + return next; + } + const next = new Set(prev); + visibleIds.forEach(id => next.add(id)); + return next; + }); + } + const filteredServers = servers.filter(server => { - const matchesGroup = selectedGroup === 'all' || server.group_id === selectedGroup; + const matchesGroup = selectedGroup === 'all' || + (selectedGroup === 'ungrouped' && !server.group_id) || + String(server.group_id) === String(selectedGroup); + const matchesStatus = selectedStatus === 'all' || server.status === selectedStatus; const matchesSearch = !searchTerm || server.name?.toLowerCase().includes(searchTerm.toLowerCase()) || server.hostname?.toLowerCase().includes(searchTerm.toLowerCase()) || server.ip_address?.toLowerCase().includes(searchTerm.toLowerCase()); - return matchesGroup && matchesSearch; + return matchesGroup && matchesStatus && matchesSearch; }); - const stats = { + const fleetStats = { total: servers.length, online: servers.filter(s => s.status === 'online').length, offline: servers.filter(s => s.status === 'offline').length, - connecting: servers.filter(s => s.status === 'connecting').length + connecting: servers.filter(s => s.status === 'connecting').length, + pending: servers.filter(s => s.status === 'pending').length }; + const availability = fleetStats.total > 0 ? Math.round((fleetStats.online / fleetStats.total) * 100) : 0; + const hasActiveFilters = selectedGroup !== 'all' || selectedStatus !== 'all' || Boolean(searchTerm); + const statCards = [ + { + key: 'all', + label: 'Total', + value: fleetStats.total, + detail: `${groups.length} group${groups.length === 1 ? '' : 's'}`, + icon: , + }, + { + key: 'online', + label: 'Online', + value: fleetStats.online, + detail: `${availability}% available`, + icon: , + }, + { + key: 'offline', + label: 'Offline', + value: fleetStats.offline, + detail: 'Needs attention', + icon: , + }, + { + key: 'connecting', + label: 'Connecting', + value: fleetStats.connecting, + detail: `${fleetStats.pending} pending`, + icon: , + } + ]; if (loading) { - return
Loading servers...
; + return ( +
+
+ + Scanning fleet... +
+
+ ); } + const visibleIds = filteredServers.map(s => s.id); + const allVisibleSelected = visibleIds.length > 0 && visibleIds.every(id => selectedIds.has(id)); + const someVisibleSelected = visibleIds.some(id => selectedIds.has(id)); + return (
-
-
+
+

Servers

-

Manage your connected servers and agents

+

{servers.length} {servers.length === 1 ? 'machine' : 'machines'} · {fleetStats.online} online · {availability}% availability

-
+
-
+
-
-
- -
-
-
{stats.total}
-
Total Servers
+ {statCards.map(stat => ( + + ))} +
+ +
+
+ +
+ Group +
-
-
- -
-
-
{stats.online}
-
Online
-
-
-
-
- -
-
-
{stats.offline}
-
Offline
-
-
-
-
- -
-
-
{stats.connecting}
-
Connecting
-
+ +
+ {filteredServers.length} + {filteredServers.length === 1 ? 'server' : 'servers'} + {hasActiveFilters && ( + + )}
-
-
- - setSearchTerm(e.target.value)} - /> + {selectedIds.size > 0 && ( +
+
+ {selectedIds.size} + selected +
+
+ + +
-
- -
-
+ )} {filteredServers.length === 0 ? ( -
- -

No servers found

+
+
+ +
+

{servers.length === 0 ? 'No servers yet' : 'No servers match these filters'}

{servers.length === 0 - ? 'Add your first server to connect an agent for monitoring and supported remote Docker actions.' - : 'No servers match your current filters.'} + ? 'Install a ServerKit agent on a machine to start monitoring health and managing Docker remotely.' + : 'Try a different status, group, or search term to bring machines back into view.'}

- {servers.length === 0 && ( + {servers.length === 0 ? ( + ) : ( + )}
) : ( -
- {filteredServers.map(server => ( - handlePingServer(server.id)} - /> - ))} +
+ + + + + + + + + + + + + + {filteredServers.map(server => ( + toggleSelect(server.id)} + onPing={() => handlePingServer(server.id)} + onDelete={() => setDeleteTarget(server)} + onCopyInstall={() => handleCopyInstall(server)} + /> + ))} + +
+ { if (el) el.indeterminate = someVisibleSelected && !allVisibleSelected; }} + onChange={() => toggleSelectAll(visibleIds)} + /> + ServerStatusGroupOS · AgentTelemetryLast seen +
)} @@ -192,115 +372,211 @@ const Servers = () => { onUpdated={loadData} /> )} + + setDeleteTarget(null)} + /> + + setBulkDeleteOpen(false)} + />
); }; -const ServerCard = ({ server, onPing }) => { - const statusColors = { - online: '#10B981', - offline: '#EF4444', - connecting: '#F59E0B', - pending: '#6B7280' +const formatLastSeen = (timestamp) => { + if (!timestamp) return 'Never'; + const date = new Date(timestamp); + const now = new Date(); + const diff = (now - date) / 1000; + if (diff < 60) return 'Just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return date.toLocaleDateString(); +}; + +const clamp = (v) => Math.min(100, Math.max(0, Number(v) || 0)); + +const ServerRow = ({ server, selected, onToggle, onPing, onDelete, onCopyInstall }) => { + const [menuPos, setMenuPos] = useState(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + const closeMenu = useCallback(() => setMenuPos(null), []); + + const openMenu = () => { + const r = triggerRef.current?.getBoundingClientRect(); + if (!r) return; + const menuHeight = 240; + const menuWidth = 220; + const flipUp = r.bottom + menuHeight + 8 > window.innerHeight; + setMenuPos({ + top: flipUp ? r.top - menuHeight - 4 : r.bottom + 4, + left: Math.max(8, r.right - menuWidth), + }); }; - const formatLastSeen = (timestamp) => { - if (!timestamp) return 'Never'; - const date = new Date(timestamp); - const now = new Date(); - const diff = (now - date) / 1000; + useEffect(() => { + if (!menuPos) return undefined; + const onDocDown = (e) => { + if ( + menuRef.current && !menuRef.current.contains(e.target) && + triggerRef.current && !triggerRef.current.contains(e.target) + ) { + closeMenu(); + } + }; + const onScroll = () => closeMenu(); + document.addEventListener('mousedown', onDocDown); + window.addEventListener('scroll', onScroll, true); + window.addEventListener('resize', onScroll); + return () => { + document.removeEventListener('mousedown', onDocDown); + window.removeEventListener('scroll', onScroll, true); + window.removeEventListener('resize', onScroll); + }; + }, [menuPos, closeMenu]); - if (diff < 60) return 'Just now'; - if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; - return date.toLocaleDateString(); + const status = server.status || 'pending'; + const displayHost = server.hostname || server.ip_address || 'Unassigned endpoint'; + const initial = (server.name || displayHost || '?').charAt(0).toUpperCase(); + const metrics = { + cpu: clamp(server.metrics?.cpu_percent), + memory: clamp(server.metrics?.memory_percent), + disk: clamp(server.metrics?.disk_percent), }; + const hasMetrics = server.metrics && status === 'online'; return ( -
-
-
-
-

{server.name}

- {server.hostname || server.ip_address} -
-
- -
-
-
- OS - {server.os_type || 'Unknown'} -
-
- Agent - {server.agent_version || 'Not installed'} -
-
- Docker - {server.docker_version || 'N/A'} -
-
- Last Seen - {formatLastSeen(server.last_seen)} -
-
- - {server.metrics && server.status === 'online' && ( -
-
- CPU -
-
-
- {(server.metrics.cpu_percent || 0).toFixed(0)}% -
-
- RAM -
-
-
- {(server.metrics.memory_percent || 0).toFixed(0)}% -
-
- Disk -
-
-
- {(server.metrics.disk_percent || 0).toFixed(0)}% -
-
- )} - - {server.group_name && ( -
+ + + + + + + + + {server.name} + {displayHost} + + + + + + + {status} + + + + {server.group_name ? ( + {server.group_name} -
+ + ) : ( + — )} -
- -
- - Details - - - - Docker - -
-
+ + +
+ {server.os_type || 'Unknown'} + {server.agent_version ? `agent ${server.agent_version}` : 'agent not installed'} +
+ + + {hasMetrics ? ( +
+ + CPU + + {metrics.cpu.toFixed(0)}% + + + RAM + + {metrics.memory.toFixed(0)}% + + + DSK + + {metrics.disk.toFixed(0)}% + +
+ ) : ( + {status === 'pending' ? 'Awaiting agent' : status === 'offline' ? 'Offline' : 'No data'} + )} + + + {formatLastSeen(server.last_seen)} + + +
+ + + + + + {menuPos && ( +
+ + View details + + + {status === 'pending' && ( + + )} + + Manage Docker + +
+ +
+ )} +
+ + ); }; @@ -315,6 +591,7 @@ const AddServerModal = ({ groups, onClose, onCreated }) => { permission_profile: 'deployment_runner', permissions: [] }); + const [showOptional, setShowOptional] = useState(false); const [registrationData, setRegistrationData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -355,80 +632,103 @@ Install-ServerKitAgent -Server "${window.location.origin}" -Token "${registratio return (
-
e.stopPropagation()}> +
e.stopPropagation()}>
-

{step === 1 ? 'Add Server' : 'Install Agent'}

+
+ {step === 1 ? 'New agent' : 'Registration ready'} +

{step === 1 ? 'Add Server' : 'Install Agent'}

+

{step === 1 ? 'Give it a name, then run the install command on the target machine.' : 'Run one command on the target machine to bring it online.'}

+
{step === 1 ? ( -
- {error &&
{error}
} + +
+ {error &&
{error}
} -
- - -
- -
- -