diff --git a/server/api/pc.py b/server/api/pc.py new file mode 100644 index 0000000..419a318 --- /dev/null +++ b/server/api/pc.py @@ -0,0 +1,95 @@ +import json +from datetime import datetime +from typing import Dict + +from fastapi import APIRouter, Request, Depends + +from server.auth import require_auth +from server.config import DATA_DIR + +router = APIRouter(prefix="/api/pc", tags=["pc"]) + +# In-memory PC metrics cache +pc_metrics: Dict[str, dict] = {} + +PC_FILE = DATA_DIR / "pc_agents.json" + + +def _load_pc_data(): + if PC_FILE.exists(): + with open(PC_FILE) as f: + return json.load(f) + return {} + + +def _save_pc_data(data): + with open(PC_FILE, "w") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +@router.post("/heartbeat") +async def pc_heartbeat(request: Request): + """Receive metrics from Windows PC agent. No auth required (agent sends by name).""" + body = await request.json() + agent_name = body.get("agent_name", "") + if not agent_name: + return {"status": "error", "detail": "agent_name required"} + + metrics = body.get("metrics", {}) + timestamp = body.get("timestamp", datetime.now().isoformat()) + + entry = { + "agent_name": agent_name, + "metrics": metrics, + "timestamp": timestamp, + "ip": request.client.host if request.client else "", + "online": True, + "last_seen": datetime.now().isoformat(), + } + + pc_metrics[agent_name] = entry + + # Persist + stored = _load_pc_data() + stored[agent_name] = entry + _save_pc_data(stored) + + return {"status": "ok"} + + +@router.get("/list") +async def pc_list(request: Request, user: str = Depends(require_auth)): + """List all PC agents with metrics.""" + stored = _load_pc_data() + + # Merge with in-memory (fresher) + for name, data in pc_metrics.items(): + stored[name] = data + + # Mark stale agents (no heartbeat > 3 min) + now = datetime.now() + result = [] + for name, data in stored.items(): + last_seen = data.get("last_seen", "") + try: + last_dt = datetime.fromisoformat(last_seen) + stale = (now - last_dt).total_seconds() > 180 + except Exception: + stale = True + + data["online"] = not stale + result.append(data) + + return result + + +@router.delete("/{agent_name}") +async def pc_delete(agent_name: str, request: Request, user: str = Depends(require_auth)): + """Remove a PC agent.""" + pc_metrics.pop(agent_name, None) + + stored = _load_pc_data() + stored.pop(agent_name, None) + _save_pc_data(stored) + + return {"status": "ok"} diff --git a/server/main.py b/server/main.py index b9d3da2..2125d22 100644 --- a/server/main.py +++ b/server/main.py @@ -16,6 +16,7 @@ from server.auth import get_current_user from server.api.servers import router as servers_router from server.api.auth_routes import router as auth_router from server.api.ssh_ws import router as ssh_router +from server.api.pc import router as pc_router from server.services.monitor import monitor_loop from server.services.telegram_bot import start_bot, stop_bot from server.services.alerter import check_alerts @@ -62,10 +63,16 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static") templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates")) +# Static downloads (Windows agent) +DOWNLOADS_DIR = os.path.join(os.path.dirname(BASE_DIR), "windows") +if os.path.isdir(DOWNLOADS_DIR): + app.mount("/static/downloads", StaticFiles(directory=DOWNLOADS_DIR), name="downloads") + # Include routers app.include_router(servers_router) app.include_router(auth_router) app.include_router(ssh_router) +app.include_router(pc_router) @app.get("/", response_class=HTMLResponse) diff --git a/server/static/css/style.css b/server/static/css/style.css index d330020..5e178bc 100644 --- a/server/static/css/style.css +++ b/server/static/css/style.css @@ -600,6 +600,39 @@ main { overflow: hidden; } +/* Tabs */ +.tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 4px; +} + +.tab { + flex: 1; + padding: 10px 16px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.tab:hover { + color: var(--text-primary); +} + +.tab.active { + background: var(--accent); + color: white; +} + /* Footer */ .app-footer { text-align: center; diff --git a/server/static/js/app.js b/server/static/js/app.js index 4f5a539..d00bb66 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -413,6 +413,115 @@ document.addEventListener('click', (e) => { } }); +// Tabs +let activeTab = 'servers'; + +function switchTab(tab) { + activeTab = tab; + document.getElementById('tab-servers').classList.toggle('active', tab === 'servers'); + document.getElementById('tab-pc').classList.toggle('active', tab === 'pc'); + document.getElementById('panel-servers').style.display = tab === 'servers' ? '' : 'none'; + document.getElementById('panel-pc').style.display = tab === 'pc' ? '' : 'none'; + + if (tab === 'pc') loadPCs(); +} + +// PC monitoring +let pcAgents = []; + +async function loadPCs() { + try { + const resp = await fetch('/api/pc/list', {credentials: 'include'}); + if (resp.status === 401) return; + pcAgents = await resp.json(); + renderPCs(); + } catch (e) { + console.error('PC load error:', e); + } +} + +function renderPCs() { + const grid = document.getElementById('pc-grid'); + + if (!pcAgents.length) { + grid.innerHTML = '
Нет подключённых ПК. Установите агент.
'; + return; + } + + grid.innerHTML = pcAgents.map(pc => { + const m = pc.metrics || {}; + const isOnline = pc.online; + const cpu = m.cpu_percent || 0; + const ram = m.ram_percent || 0; + const disk = m.disk_percent || 0; + + const cpuClass = cpu > 90 ? 'crit' : cpu > 70 ? 'warn' : ''; + const ramClass = ram > 90 ? 'crit' : ram > 70 ? 'warn' : ''; + const diskClass = disk > 90 ? 'crit' : disk > 70 ? 'warn' : ''; + + const disksHtml = (m.disks || []).map(d => + `
${d.drive} ${d.used_gb}/${d.total_gb} GB (${d.percent}%)
` + ).join(''); + + const procsHtml = (m.top_processes || []).slice(0, 3).map(p => + `${p.name} ${p.ram_mb}MB` + ).join(' '); + + return ` +
+
+
+
💻 ${pc.agent_name}
+
${m.hostname || ''} • ${pc.ip || ''}
+
+ + + ${isOnline ? 'Online' : 'Offline'} + +
+ ${isOnline ? ` +
+
+ CPU + ${cpu}% +
+
+
+ RAM + ${ram}% +
+
+
+ Disk + ${disk}% +
+
+
+
+ ${m.uptime || ''} ${m.os ? '• ' + m.os.substring(0, 30) : ''} +
+ ${m.gpu_name ? `
GPU: ${m.gpu_name}
` : ''} + ${disksHtml} + ${procsHtml ? `
${procsHtml}
` : ''} + ` : '
Нет данных
'} +
+ +
+
+ `; + }).join(''); +} + +async function deletePC(name) { + if (!confirm(`Удалить ПК "${name}" из мониторинга?`)) return; + await fetch(`/api/pc/${encodeURIComponent(name)}`, {method: 'DELETE', credentials: 'include'}); + loadPCs(); +} + +function showPCSetup() { + document.getElementById('pcSetupModal').style.display = 'flex'; +} + // Initial load document.addEventListener('DOMContentLoaded', () => { applyTheme(currentTheme); @@ -420,4 +529,7 @@ document.addEventListener('DOMContentLoaded', () => { loadServers(); }); -setInterval(loadServers, 30000); +setInterval(() => { + if (activeTab === 'servers') loadServers(); + else loadPCs(); +}, 30000); diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html index 743d857..b4cf9bf 100644 --- a/server/templates/dashboard.html +++ b/server/templates/dashboard.html @@ -40,11 +40,28 @@ -
- + +
+ +
-
+ +
+
+ +
+
+
+ + + @@ -165,6 +182,46 @@
+ + + diff --git a/windows/README_PC.md b/windows/README_PC.md new file mode 100644 index 0000000..c3cb948 --- /dev/null +++ b/windows/README_PC.md @@ -0,0 +1,50 @@ +# VPS Monitor — Windows PC Agent + +## Quick Install (one command) + +Run in PowerShell as Administrator: + +```powershell +irm https://77.239.126.123.nip.io/static/downloads/install_agent.ps1 | iex -ServerUrl "http://77.239.126.123:7272" -AgentName "MyPC" +``` + +Or download and run: +```powershell +Invoke-WebRequest -Uri "http://77.239.126.123:7272/static/downloads/install_agent.ps1" -OutFile install_agent.ps1 +powershell -ExecutionPolicy Bypass -File install_agent.ps1 -ServerUrl "http://77.239.126.123:7272" -AgentName "Office-PC" +``` + +## What it monitors + +- CPU usage (%) +- RAM usage (total, used, %) +- Disk usage (all drives) +- Network adapters (speed, traffic) +- GPU info +- Uptime +- Top 5 processes by CPU + +## Files + +| File | Purpose | +|------|---------| +| `install_agent.ps1` | Installer (creates task, config) | +| `vps_monitor_agent.ps1` | Agent (collects & sends metrics) | +| `C:\VPS-Monitor\agent_config.json` | Config (server URL, name) | + +## Management + +```powershell +# Check status +Get-ScheduledTask 'VPS-Monitor-Agent' + +# Stop +Stop-ScheduledTask 'VPS-Monitor-Agent' + +# Start +Start-ScheduledTask 'VPS-Monitor-Agent' + +# Uninstall +Unregister-ScheduledTask 'VPS-Monitor-Agent' -Confirm:$false +Remove-Item -Recurse C:\VPS-Monitor +``` diff --git a/windows/install_agent.ps1 b/windows/install_agent.ps1 new file mode 100644 index 0000000..66c96c0 --- /dev/null +++ b/windows/install_agent.ps1 @@ -0,0 +1,114 @@ +# VPS Monitor — Windows Agent Installer +# Usage: powershell -ExecutionPolicy Bypass -File install_agent.ps1 -ServerUrl "http://IP:7272" -AgentName "MyPC" + +param( + [Parameter(Mandatory=$true)] + [string]$ServerUrl, + + [string]$AgentName = $env:COMPUTERNAME, + + [int]$Interval = 60 +) + +$InstallDir = "C:\VPS-Monitor" +$TaskName = "VPS-Monitor-Agent" + +Write-Host "================================================" -ForegroundColor Cyan +Write-Host " VPS Monitor — Agent Installer" -ForegroundColor Cyan +Write-Host "================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host " Server: $ServerUrl" -ForegroundColor White +Write-Host " Name: $AgentName" -ForegroundColor White +Write-Host " Interval: ${Interval}s" -ForegroundColor White +Write-Host " Install: $InstallDir" -ForegroundColor White +Write-Host "" + +# Create install directory +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Write-Host "[+] Created $InstallDir" -ForegroundColor Green +} + +# Download agent script +Write-Host "[*] Downloading agent..." -ForegroundColor Yellow +$AgentUrl = "$ServerUrl/static/downloads/vps_monitor_agent.ps1" +try { + Invoke-WebRequest -Uri $AgentUrl -OutFile "$InstallDir\vps_monitor_agent.ps1" -UseBasicParsing + Write-Host "[+] Agent downloaded" -ForegroundColor Green +} catch { + Write-Host "[!] Download failed, using local copy..." -ForegroundColor Yellow + # Fallback: copy from same directory + $localAgent = Join-Path $PSScriptRoot "vps_monitor_agent.ps1" + if (Test-Path $localAgent) { + Copy-Item $localAgent "$InstallDir\vps_monitor_agent.ps1" + Write-Host "[+] Local copy used" -ForegroundColor Green + } else { + Write-Host "[-] No agent script found!" -ForegroundColor Red + exit 1 + } +} + +# Create config +$config = @{ + server_url = $ServerUrl + agent_name = $AgentName + interval = $Interval +} | ConvertTo-Json + +Set-Content -Path "$InstallDir\agent_config.json" -Value $config +Write-Host "[+] Config saved" -ForegroundColor Green + +# Create scheduled task (run at startup + every 5 min check) +$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +if ($existingTask) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false + Write-Host "[*] Removed old task" -ForegroundColor Yellow +} + +$action = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$InstallDir\vps_monitor_agent.ps1`"" ` + -WorkingDirectory $InstallDir + +$triggerStartup = New-ScheduledTaskTrigger -AtStartup +$triggerNow = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1) + +$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest + +$settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 1) ` + -ExecutionTimeLimit (New-TimeSpan -Days 365) + +Register-ScheduledTask ` + -TaskName $TaskName ` + -Action $action ` + -Trigger $triggerStartup ` + -Principal $principal ` + -Settings $settings ` + -Description "VPS Monitor PC Agent — sends metrics to $ServerUrl" | Out-Null + +Write-Host "[+] Scheduled task created" -ForegroundColor Green + +# Start now +Start-ScheduledTask -TaskName $TaskName +Write-Host "[+] Agent started!" -ForegroundColor Green + +Write-Host "" +Write-Host "================================================" -ForegroundColor Cyan +Write-Host " Installation complete!" -ForegroundColor Green +Write-Host "================================================" -ForegroundColor Cyan +Write-Host "" +Write-Host " Files: $InstallDir" -ForegroundColor White +Write-Host " Task: $TaskName" -ForegroundColor White +Write-Host " Status: Running" -ForegroundColor White +Write-Host "" +Write-Host " Commands:" -ForegroundColor Gray +Write-Host " Check: Get-ScheduledTask '$TaskName'" -ForegroundColor Gray +Write-Host " Stop: Stop-ScheduledTask '$TaskName'" -ForegroundColor Gray +Write-Host " Start: Start-ScheduledTask '$TaskName'" -ForegroundColor Gray +Write-Host " Remove: Unregister-ScheduledTask '$TaskName'" -ForegroundColor Gray +Write-Host " Logs: type $InstallDir\agent_config.json" -ForegroundColor Gray +Write-Host "" diff --git a/windows/vps_monitor_agent.ps1 b/windows/vps_monitor_agent.ps1 new file mode 100644 index 0000000..7aa712b --- /dev/null +++ b/windows/vps_monitor_agent.ps1 @@ -0,0 +1,160 @@ +# VPS Monitor — Windows PC Agent +# Sends system metrics to VPS Monitoring server +# +# Install: Run install_agent.ps1 +# Manual: powershell -ExecutionPolicy Bypass -File vps_monitor_agent.ps1 + +param( + [string]$ServerUrl = "", + [string]$AgentName = "", + [int]$Interval = 60 +) + +# Load config +$ConfigPath = Join-Path $PSScriptRoot "agent_config.json" +if (Test-Path $ConfigPath) { + $config = Get-Content $ConfigPath | ConvertFrom-Json + if (-not $ServerUrl) { $ServerUrl = $config.server_url } + if (-not $AgentName) { $AgentName = $config.agent_name } + if ($config.interval) { $Interval = $config.interval } +} + +if (-not $ServerUrl -or -not $AgentName) { + Write-Host "ERROR: server_url and agent_name required" -ForegroundColor Red + Write-Host "Edit agent_config.json or pass -ServerUrl and -AgentName" + exit 1 +} + +Write-Host "================================================" -ForegroundColor Cyan +Write-Host " VPS Monitor — PC Agent" -ForegroundColor Cyan +Write-Host " Server: $ServerUrl" -ForegroundColor Gray +Write-Host " Name: $AgentName" -ForegroundColor Gray +Write-Host " Interval: ${Interval}s" -ForegroundColor Gray +Write-Host "================================================" -ForegroundColor Cyan +Write-Host "" + +function Get-SystemMetrics { + $metrics = @{} + + # Hostname + $metrics["hostname"] = $env:COMPUTERNAME + + # OS + $os = Get-CimInstance Win32_OperatingSystem + $metrics["os"] = "$($os.Caption) $($os.Version)" + + # CPU + $cpu = Get-CimInstance Win32_Processor + $metrics["cpu_name"] = $cpu.Name + $metrics["cpu_percent"] = [math]::Round((Get-Counter '\Processor(_Total)\% Processor Time' -ErrorAction SilentlyContinue).CounterSamples[0].CookedValue, 1) + + # RAM + $totalRam = [math]::Round($os.TotalVisibleMemorySize / 1024, 0) + $freeRam = [math]::Round($os.FreePhysicalMemory / 1024, 0) + $usedRam = $totalRam - $freeRam + $metrics["ram_total_mb"] = $totalRam + $metrics["ram_used_mb"] = $usedRam + $metrics["ram_percent"] = [math]::Round(($usedRam / $totalRam) * 100, 1) + + # Disk (all drives) + $disks = @() + Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object { + $totalGb = [math]::Round($_.Size / 1GB, 1) + $freeGb = [math]::Round($_.FreeSpace / 1GB, 1) + $usedGb = $totalGb - $freeGb + $pct = if ($totalGb -gt 0) { [math]::Round(($usedGb / $totalGb) * 100, 1) } else { 0 } + $disks += @{ + drive = $_.DeviceID + total_gb = $totalGb + used_gb = $usedGb + free_gb = $freeGb + percent = $pct + } + } + $metrics["disks"] = $disks + + # Primary disk summary + $primary = $disks | Where-Object { $_.drive -eq "C:" } | Select-Object -First 1 + if ($primary) { + $metrics["disk_total_gb"] = $primary.total_gb + $metrics["disk_used_gb"] = $primary.used_gb + $metrics["disk_percent"] = $primary.percent + } + + # Network + $adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" } + $netInfo = @() + foreach ($a in $adapters) { + $stats = Get-NetAdapterStatistics -Name $a.Name -ErrorAction SilentlyContinue + $netInfo += @{ + name = $a.Name + speed_mbps = [math]::Round($a.LinkSpeed.Replace(" Gbps","000").Replace(" Mbps","") -as [double], 0) + sent_mb = if ($stats) { [math]::Round($stats.SentBytes / 1MB, 0) } else { 0 } + received_mb = if ($stats) { [math]::Round($stats.ReceivedBytes / 1MB, 0) } else { 0 } + } + } + $metrics["network"] = $netInfo + + # Uptime + $uptime = (Get-Date) - $os.LastBootUpTime + $metrics["uptime"] = "up $($uptime.Days)d $($uptime.Hours)h $($uptime.Minutes)m" + $metrics["uptime_seconds"] = [math]::Round($uptime.TotalSeconds, 0) + + # GPU (if available) + try { + $gpu = Get-CimInstance Win32_VideoController | Select-Object -First 1 + if ($gpu) { + $metrics["gpu_name"] = $gpu.Name + $metrics["gpu_ram_mb"] = [math]::Round($gpu.AdapterRAM / 1MB, 0) + } + } catch {} + + # Top processes by CPU + $topProcs = Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 | + ForEach-Object { @{ name = $_.ProcessName; cpu = [math]::Round($_.CPU, 1); ram_mb = [math]::Round($_.WorkingSet64 / 1MB, 0) } } + $metrics["top_processes"] = $topProcs + + return $metrics +} + +function Send-Metrics($metrics) { + $body = @{ + agent_name = $AgentName + timestamp = (Get-Date).ToString("o") + metrics = $metrics + } | ConvertTo-Json -Depth 5 + + try { + $response = Invoke-RestMethod -Uri "$ServerUrl/api/pc/heartbeat" ` + -Method POST ` + -Body $body ` + -ContentType "application/json" ` + -TimeoutSec 15 + + return $true + } catch { + Write-Host " [!] Send failed: $($_.Exception.Message)" -ForegroundColor Yellow + return $false + } +} + +# Main loop +Write-Host "Agent started. Press Ctrl+C to stop." -ForegroundColor Green +Write-Host "" + +while ($true) { + $ts = Get-Date -Format "HH:mm:ss" + + try { + $metrics = Get-SystemMetrics + $ok = Send-Metrics $metrics + + if ($ok) { + Write-Host "[$ts] OK — CPU: $($metrics.cpu_percent)% RAM: $($metrics.ram_percent)% Disk: $($metrics.disk_percent)%" -ForegroundColor Green + } + } catch { + Write-Host "[$ts] ERROR: $($_.Exception.Message)" -ForegroundColor Red + } + + Start-Sleep -Seconds $Interval +}