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 `
+
+
+ ${isOnline ? `
+
+
+ ${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 @@
-
+
+
+
+
+
+
+
📥 Быстрая установка (PowerShell от Администратора)
+ powershell -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri 'http://77.239.126.123:7272/static/downloads/install_agent.ps1' -OutFile install_agent.ps1; .\install_agent.ps1 -ServerUrl 'http://77.239.126.123:7272' -AgentName 'MyPC'"
+
+
+
⚙️ Параметры
+
-ServerUrl — адрес сервера мониторинга
+ -AgentName — имя ПК (по умолчанию hostname)
+ -Interval — интервал отправки в секундах (по умолчанию 60)
+
+
+
📊 Что мониторится
+
CPU, RAM, Disk (все диски), Network, GPU, Uptime, Top 5 процессов
+
+
+
🔧 Управление
+
+ - Get-ScheduledTask 'VPS-Monitor-Agent'
+ - Stop-ScheduledTask 'VPS-Monitor-Agent'
+ - Start-ScheduledTask 'VPS-Monitor-Agent'
+ - Unregister-ScheduledTask 'VPS-Monitor-Agent'
+
+
+
+
📁 Файлы
+
Агент: C:\VPS-Monitor\vps_monitor_agent.ps1
+ Конфиг: C:\VPS-Monitor\agent_config.json
+
+
+
+
+
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
+}