Add active user session tracking from agent user :0 2026-07-16 15:46
user pts/0 2026-07-16 15:50 (:0) user pts/1 2026-07-16 16:46 (:0) user pts/2 2026-07-17 09:05 (:0) to server UI
This commit is contained in:
@@ -7,6 +7,7 @@ import getpass
|
|||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -70,6 +71,62 @@ def get_disk_info():
|
|||||||
return disks
|
return disks
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_sessions():
|
||||||
|
"""Return active user sessions similar to the `who` command.
|
||||||
|
|
||||||
|
Each session is a dict with keys: username, tty, login_time, origin.
|
||||||
|
Falls back to the current process user if `who` is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["who", "-u"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
sessions = []
|
||||||
|
for line in result.stdout.strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 5:
|
||||||
|
continue
|
||||||
|
username = parts[0]
|
||||||
|
tty = parts[1]
|
||||||
|
# login time is usually "YYYY-MM-DD HH:MM" (parts[2] and parts[3])
|
||||||
|
login_time = " ".join(parts[2:4]) if len(parts) >= 4 else ""
|
||||||
|
# The last field may be a comment in parentheses, e.g. (192.168.255.50)
|
||||||
|
origin = ""
|
||||||
|
if parts[-1].startswith("(") and parts[-1].endswith(")"):
|
||||||
|
origin = parts[-1][1:-1]
|
||||||
|
sessions.append({
|
||||||
|
"username": username,
|
||||||
|
"tty": tty,
|
||||||
|
"login_time": login_time,
|
||||||
|
"origin": origin,
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_display_username(sessions):
|
||||||
|
"""Return a short username representation for the dashboard.
|
||||||
|
|
||||||
|
Single user -> username
|
||||||
|
Multiple -> comma-separated usernames
|
||||||
|
No sessions -> fallback to process user
|
||||||
|
"""
|
||||||
|
usernames = [s["username"] for s in sessions if s.get("username")]
|
||||||
|
if not usernames:
|
||||||
|
return getpass.getuser()
|
||||||
|
return ", ".join(dict.fromkeys(usernames))
|
||||||
|
|
||||||
|
|
||||||
def get_cpu_temp():
|
def get_cpu_temp():
|
||||||
try:
|
try:
|
||||||
temps = psutil.sensors_temperatures()
|
temps = psutil.sensors_temperatures()
|
||||||
@@ -108,9 +165,11 @@ def get_top_processes(limit=10):
|
|||||||
|
|
||||||
def collect():
|
def collect():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
sessions = get_active_sessions()
|
||||||
payload = {
|
payload = {
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"username": getpass.getuser(),
|
"username": get_display_username(sessions),
|
||||||
|
"sessions": sessions,
|
||||||
"os_info": f"{platform.system()} {platform.release()}",
|
"os_info": f"{platform.system()} {platform.release()}",
|
||||||
"local_ip": get_local_ip(),
|
"local_ip": get_local_ip(),
|
||||||
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
|
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
|
||||||
|
|||||||
+60
-1
@@ -7,6 +7,7 @@ import getpass
|
|||||||
import json
|
import json
|
||||||
import requests
|
import requests
|
||||||
import logging
|
import logging
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -70,6 +71,62 @@ def get_disk_info():
|
|||||||
return disks
|
return disks
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_sessions():
|
||||||
|
"""Return active user sessions similar to the `who` command.
|
||||||
|
|
||||||
|
Each session is a dict with keys: username, tty, login_time, origin.
|
||||||
|
Falls back to the current process user if `who` is unavailable.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["who", "-u"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return []
|
||||||
|
sessions = []
|
||||||
|
for line in result.stdout.strip().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) < 5:
|
||||||
|
continue
|
||||||
|
username = parts[0]
|
||||||
|
tty = parts[1]
|
||||||
|
# login time is usually "YYYY-MM-DD HH:MM" (parts[2] and parts[3])
|
||||||
|
login_time = " ".join(parts[2:4]) if len(parts) >= 4 else ""
|
||||||
|
# The last field may be a comment in parentheses, e.g. (192.168.255.50)
|
||||||
|
origin = ""
|
||||||
|
if parts[-1].startswith("(") and parts[-1].endswith(")"):
|
||||||
|
origin = parts[-1][1:-1]
|
||||||
|
sessions.append({
|
||||||
|
"username": username,
|
||||||
|
"tty": tty,
|
||||||
|
"login_time": login_time,
|
||||||
|
"origin": origin,
|
||||||
|
})
|
||||||
|
return sessions
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_display_username(sessions):
|
||||||
|
"""Return a short username representation for the dashboard.
|
||||||
|
|
||||||
|
Single user -> username
|
||||||
|
Multiple -> comma-separated usernames
|
||||||
|
No sessions -> fallback to process user
|
||||||
|
"""
|
||||||
|
usernames = [s["username"] for s in sessions if s.get("username")]
|
||||||
|
if not usernames:
|
||||||
|
return getpass.getuser()
|
||||||
|
return ", ".join(dict.fromkeys(usernames))
|
||||||
|
|
||||||
|
|
||||||
def get_cpu_temp():
|
def get_cpu_temp():
|
||||||
try:
|
try:
|
||||||
temps = psutil.sensors_temperatures()
|
temps = psutil.sensors_temperatures()
|
||||||
@@ -108,9 +165,11 @@ def get_top_processes(limit=10):
|
|||||||
|
|
||||||
def collect():
|
def collect():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
sessions = get_active_sessions()
|
||||||
payload = {
|
payload = {
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
"username": getpass.getuser(),
|
"username": get_display_username(sessions),
|
||||||
|
"sessions": sessions,
|
||||||
"os_info": f"{platform.system()} {platform.release()}",
|
"os_info": f"{platform.system()} {platform.release()}",
|
||||||
"local_ip": get_local_ip(),
|
"local_ip": get_local_ip(),
|
||||||
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
|
"public_ip": get_public_ip() if cfg.get("send_public_ip") else None,
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ def run_migrations():
|
|||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn.execute(text("ALTER TABLE audit_log ADD COLUMN username TEXT"))
|
conn.execute(text("ALTER TABLE audit_log ADD COLUMN username TEXT"))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
# Heartbeat: add sessions column if missing
|
||||||
|
if "heartbeats" in insp.get_table_names():
|
||||||
|
hb_cols = {c["name"] for c in insp.get_columns("heartbeats")}
|
||||||
|
if "sessions" not in hb_cols:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE heartbeats ADD COLUMN sessions TEXT"))
|
||||||
|
conn.commit()
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ class Heartbeat(Base):
|
|||||||
cpu_temp = Column(Float, nullable=True)
|
cpu_temp = Column(Float, nullable=True)
|
||||||
load_avg = Column(String, nullable=True)
|
load_avg = Column(String, nullable=True)
|
||||||
processes = Column(JSON, nullable=True)
|
processes = Column(JSON, nullable=True)
|
||||||
|
sessions = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(Base):
|
class AuditLog(Base):
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ def heartbeat(
|
|||||||
computer.current_ram_percent = payload.ram_percent
|
computer.current_ram_percent = payload.ram_percent
|
||||||
computer.current_ram_total_gb = payload.ram_total_gb
|
computer.current_ram_total_gb = payload.ram_total_gb
|
||||||
|
|
||||||
|
sessions = payload.sessions
|
||||||
|
if sessions:
|
||||||
|
sessions = [s.model_dump() for s in sessions]
|
||||||
|
|
||||||
heartbeat = Heartbeat(
|
heartbeat = Heartbeat(
|
||||||
computer_id=computer.id,
|
computer_id=computer.id,
|
||||||
timestamp=now,
|
timestamp=now,
|
||||||
@@ -83,6 +87,7 @@ def heartbeat(
|
|||||||
cpu_temp=payload.cpu_temp,
|
cpu_temp=payload.cpu_temp,
|
||||||
load_avg=payload.load_avg,
|
load_avg=payload.load_avg,
|
||||||
processes=payload.processes or [],
|
processes=payload.processes or [],
|
||||||
|
sessions=sessions,
|
||||||
)
|
)
|
||||||
db.add(heartbeat)
|
db.add(heartbeat)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ def computer_page(computer_id: int, request: Request, db: Session = Depends(get_
|
|||||||
)
|
)
|
||||||
|
|
||||||
hb_data = []
|
hb_data = []
|
||||||
|
latest_sessions = []
|
||||||
for h in heartbeats:
|
for h in heartbeats:
|
||||||
hb_data.append({
|
hb_data.append({
|
||||||
"timestamp": h.timestamp.isoformat() if h.timestamp else None,
|
"timestamp": h.timestamp.isoformat() if h.timestamp else None,
|
||||||
@@ -124,7 +125,10 @@ def computer_page(computer_id: int, request: Request, db: Session = Depends(get_
|
|||||||
"ram_percent": h.ram_percent,
|
"ram_percent": h.ram_percent,
|
||||||
"cpu_temp": h.cpu_temp,
|
"cpu_temp": h.cpu_temp,
|
||||||
"processes": h.processes or [],
|
"processes": h.processes or [],
|
||||||
|
"sessions": h.sessions or [],
|
||||||
})
|
})
|
||||||
|
if not latest_sessions and h.sessions:
|
||||||
|
latest_sessions = h.sessions
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"computer.html",
|
"computer.html",
|
||||||
@@ -133,6 +137,7 @@ def computer_page(computer_id: int, request: Request, db: Session = Depends(get_
|
|||||||
"user": current_user,
|
"user": current_user,
|
||||||
"computer": computer,
|
"computer": computer,
|
||||||
"heartbeats": hb_data,
|
"heartbeats": hb_data,
|
||||||
|
"latest_sessions": latest_sessions,
|
||||||
"unread_count": get_unread_count(db),
|
"unread_count": get_unread_count(db),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ class LoginPayload(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SessionInfo(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
tty: Optional[str] = None
|
||||||
|
login_time: Optional[str] = None
|
||||||
|
origin: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatPayload(BaseModel):
|
class HeartbeatPayload(BaseModel):
|
||||||
hostname: str
|
hostname: str
|
||||||
username: str
|
username: str
|
||||||
@@ -60,6 +67,7 @@ class HeartbeatPayload(BaseModel):
|
|||||||
cpu_temp: Optional[float] = None
|
cpu_temp: Optional[float] = None
|
||||||
load_avg: Optional[str] = None
|
load_avg: Optional[str] = None
|
||||||
processes: Optional[List[dict]] = None
|
processes: Optional[List[dict]] = None
|
||||||
|
sessions: Optional[List[SessionInfo]] = None
|
||||||
|
|
||||||
|
|
||||||
class HeartbeatOut(BaseModel):
|
class HeartbeatOut(BaseModel):
|
||||||
@@ -70,6 +78,7 @@ class HeartbeatOut(BaseModel):
|
|||||||
cpu_percent: Optional[float]
|
cpu_percent: Optional[float]
|
||||||
ram_percent: Optional[float]
|
ram_percent: Optional[float]
|
||||||
cpu_temp: Optional[float]
|
cpu_temp: Optional[float]
|
||||||
|
sessions: Optional[List[SessionInfo]] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,11 @@
|
|||||||
<div class="card text-center shadow-sm">
|
<div class="card text-center shadow-sm">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="text-muted small">Пользователь</div>
|
<div class="text-muted small">Пользователь</div>
|
||||||
|
{% if computer.current_user and ',' in computer.current_user %}
|
||||||
|
<span class="badge bg-info text-dark" title="{{ computer.current_user }}">за ПК работают {{ computer.current_user.split(',') | length }} пользователя</span>
|
||||||
|
{% else %}
|
||||||
<div class="fw-bold">{{ computer.current_user or '-' }}</div>
|
<div class="fw-bold">{{ computer.current_user or '-' }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,6 +49,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Активные сессии</h5>
|
||||||
|
{% if latest_sessions %}
|
||||||
|
<table class="table table-sm mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Пользователь</th><th>TTY</th><th>Время входа</th><th>Откуда</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in latest_sessions %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.username or '-' }}</td>
|
||||||
|
<td>{{ s.tty or '-' }}</td>
|
||||||
|
<td>{{ s.login_time or '-' }}</td>
|
||||||
|
<td>{{ s.origin or '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">Нет данных о сессиях.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="row g-4 mb-4">
|
<div class="row g-4 mb-4">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
|
|||||||
@@ -64,11 +64,17 @@
|
|||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for c in computers %}
|
{% for c in computers %}
|
||||||
<tr data-id="{{ c.id }}">
|
<tr data-id="{{ c.id }}">
|
||||||
<td><strong>{{ c.hostname }}</strong></td>
|
<td><strong>{{ c.hostname }}</strong></td>
|
||||||
<td>{{ c.current_user or '-' }}</td>
|
<td>
|
||||||
|
{% if c.current_user and ',' in c.current_user %}
|
||||||
|
<span class="badge bg-info text-dark" title="{{ c.current_user }}">за ПК работают {{ c.current_user.split(',') | length }} пользователя</span>
|
||||||
|
{% else %}
|
||||||
|
{{ c.current_user or '-' }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
<td>{{ c.current_ip or '-' }}</td>
|
<td>{{ c.current_ip or '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if c.status == 'online' %}
|
{% if c.status == 'online' %}
|
||||||
@@ -101,6 +107,15 @@
|
|||||||
<script>
|
<script>
|
||||||
const isAdmin = {{ 'true' if user.role == 'admin' else 'false' }};
|
const isAdmin = {{ 'true' if user.role == 'admin' else 'false' }};
|
||||||
|
|
||||||
|
function formatCurrentUser(value) {
|
||||||
|
if (!value) return '-';
|
||||||
|
if (value.includes(',')) {
|
||||||
|
const count = value.split(',').length;
|
||||||
|
return `<span class="badge bg-info text-dark" title="${escapeHtml(value)}">за ПК работают ${count} пользователя</span>`;
|
||||||
|
}
|
||||||
|
return escapeHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
function currentQuery() {
|
function currentQuery() {
|
||||||
return new URLSearchParams(window.location.search).toString();
|
return new URLSearchParams(window.location.search).toString();
|
||||||
}
|
}
|
||||||
@@ -152,7 +167,7 @@ async function loadDashboard() {
|
|||||||
tbody.innerHTML = data.map(c => `
|
tbody.innerHTML = data.map(c => `
|
||||||
<tr data-id="${c.id}">
|
<tr data-id="${c.id}">
|
||||||
<td><strong>${escapeHtml(c.hostname)}</strong></td>
|
<td><strong>${escapeHtml(c.hostname)}</strong></td>
|
||||||
<td>${escapeHtml(c.current_user || '-')}</td>
|
<td>${formatCurrentUser(c.current_user)}</td>
|
||||||
<td>${escapeHtml(c.current_ip || '-')}</td>
|
<td>${escapeHtml(c.current_ip || '-')}</td>
|
||||||
<td>${c.status === 'online' ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>'}</td>
|
<td>${c.status === 'online' ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>'}</td>
|
||||||
<td>${c.current_cpu_percent != null ? c.current_cpu_percent.toFixed(1) : '-'}%</td>
|
<td>${c.current_cpu_percent != null ? c.current_cpu_percent.toFixed(1) : '-'}%</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user