feat: add PC monitoring tab with Windows agent

- New /api/pc endpoints (heartbeat, list, delete)
- Dashboard tabs: Серверы / ПК with auto-refresh
- PowerShell agent: CPU, RAM, Disk, Network, GPU, Top processes
- One-command Windows installer with Scheduled Task
- PC setup instructions modal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Андрей Бобырев
2026-05-20 13:25:14 +03:00
parent f9f9195e14
commit 399e9ab2fb
8 changed files with 632 additions and 4 deletions

95
server/api/pc.py Normal file
View File

@@ -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"}

View File

@@ -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)

View File

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

View File

@@ -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 = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет подключённых ПК. Установите агент.</div>';
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 =>
`<div style="font-size:11px;color:var(--text-secondary);margin-top:2px">${d.drive} ${d.used_gb}/${d.total_gb} GB (${d.percent}%)</div>`
).join('');
const procsHtml = (m.top_processes || []).slice(0, 3).map(p =>
`<span style="font-size:10px;padding:2px 6px;background:var(--bg-primary);border-radius:4px;margin:1px">${p.name} ${p.ram_mb}MB</span>`
).join(' ');
return `
<div class="server-card ${isOnline ? 'online' : 'offline'}">
<div class="server-card-header">
<div>
<div class="name">💻 ${pc.agent_name}</div>
<div class="host">${m.hostname || ''}${pc.ip || ''}</div>
</div>
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
${isOnline ? 'Online' : 'Offline'}
</span>
</div>
${isOnline ? `
<div class="metrics-grid">
<div class="metric-item">
<span class="label">CPU</span>
<span class="value ${cpuClass}">${cpu}%</span>
<div class="progress-bar"><div class="fill ${cpuClass || 'ok'}" style="width:${cpu}%"></div></div>
</div>
<div class="metric-item">
<span class="label">RAM</span>
<span class="value ${ramClass}">${ram}%</span>
<div class="progress-bar"><div class="fill ${ramClass || 'ok'}" style="width:${ram}%"></div></div>
</div>
<div class="metric-item">
<span class="label">Disk</span>
<span class="value ${diskClass}">${disk}%</span>
<div class="progress-bar"><div class="fill ${diskClass || 'ok'}" style="width:${disk}%"></div></div>
</div>
</div>
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
${m.uptime || ''} ${m.os ? '• ' + m.os.substring(0, 30) : ''}
</div>
${m.gpu_name ? `<div style="font-size:11px;color:var(--text-secondary)">GPU: ${m.gpu_name}</div>` : ''}
${disksHtml}
${procsHtml ? `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:2px">${procsHtml}</div>` : ''}
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных</div>'}
<div class="server-card-actions">
<button class="danger" onclick="deletePC('${pc.agent_name}')">🗑 Удалить</button>
</div>
</div>
`;
}).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);

View File

@@ -40,11 +40,28 @@
</div>
</div>
<div class="toolbar">
<button class="btn-primary" id="addServerBtn" onclick="showAddServer()">+ Добавить сервер</button>
<!-- Tabs -->
<div class="tabs">
<button class="tab active" id="tab-servers" onclick="switchTab('servers')">🖥 Серверы</button>
<button class="tab" id="tab-pc" onclick="switchTab('pc')">💻 ПК</button>
</div>
<div class="servers-grid" id="servers-grid"></div>
<!-- Servers Tab -->
<div id="panel-servers">
<div class="toolbar">
<button class="btn-primary" id="addServerBtn" onclick="showAddServer()">+ Добавить сервер</button>
</div>
<div class="servers-grid" id="servers-grid"></div>
</div>
<!-- PC Tab -->
<div id="panel-pc" style="display:none">
<div class="toolbar">
<button class="btn-secondary" onclick="showPCSetup()">📋 Инструкция установки</button>
<button class="btn-secondary" onclick="loadPCs()">🔄 Обновить</button>
</div>
<div class="servers-grid" id="pc-grid"></div>
</div>
</main>
<!-- Add Server Modal -->
@@ -165,6 +182,46 @@
</div>
</div>
<!-- PC Setup Modal -->
<div class="modal" id="pcSetupModal" style="display:none">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2>💻 Установка агента на ПК</h2>
<button class="btn-close" onclick="closeModal('pcSetupModal')">&times;</button>
</div>
<div class="instructions-section">
<div class="instr-card">
<h4>📥 Быстрая установка (PowerShell от Администратора)</h4>
<code id="pc-install-cmd">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'"</code>
</div>
<div class="instr-card">
<h4>⚙️ Параметры</h4>
<p><b>-ServerUrl</b> — адрес сервера мониторинга<br>
<b>-AgentName</b> — имя ПК (по умолчанию hostname)<br>
<b>-Interval</b> — интервал отправки в секундах (по умолчанию 60)</p>
</div>
<div class="instr-card">
<h4>📊 Что мониторится</h4>
<p>CPU, RAM, Disk (все диски), Network, GPU, Uptime, Top 5 процессов</p>
</div>
<div class="instr-card">
<h4>🔧 Управление</h4>
<ul class="cmd-list">
<li>Get-ScheduledTask 'VPS-Monitor-Agent'</li>
<li>Stop-ScheduledTask 'VPS-Monitor-Agent'</li>
<li>Start-ScheduledTask 'VPS-Monitor-Agent'</li>
<li>Unregister-ScheduledTask 'VPS-Monitor-Agent'</li>
</ul>
</div>
<div class="instr-card">
<h4>📁 Файлы</h4>
<p>Агент: <code style="display:inline">C:\VPS-Monitor\vps_monitor_agent.ps1</code><br>
Конфиг: <code style="display:inline">C:\VPS-Monitor\agent_config.json</code></p>
</div>
</div>
</div>
</div>
<footer class="app-footer">
автор:&ensp;<a href="https://github.com/andrey271192" target="_blank">GitHub</a>&ensp;·&ensp;<a href="https://boosty.to/iot_andrey" target="_blank">Boosty</a>&ensp;·&ensp;<a href="https://t.me/Iot_andrey" target="_blank">Поддержка</a>&ensp;·&ensp;<a href="https://t.me/Iot_andrey" target="_blank">@Iot_andrey</a>
</footer>