323 lines
10 KiB
Python
323 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
App Usage Tracker — 进程 + Docker 容器资源用量排行
|
|
按日/周/月/年/总计 聚合 CPU、内存、网络流量
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import time
|
|
from datetime import date, datetime, timedelta
|
|
|
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app_usage.db')
|
|
|
|
_LAST_SNAPSHOT_TS = 0
|
|
_SNAPSHOT_INTERVAL = 60 # collect snapshots every 60s
|
|
|
|
|
|
def _get_db():
|
|
conn = sqlite3.connect(DB_PATH)
|
|
c = conn.cursor()
|
|
c.execute('''CREATE TABLE IF NOT EXISTS snapshots (
|
|
ts INTEGER, app TEXT, cpu_pct REAL, mem_mb REAL,
|
|
net_rx INTEGER, net_tx INTEGER
|
|
)''')
|
|
c.execute('CREATE INDEX IF NOT EXISTS idx_snap_ts ON snapshots(ts)')
|
|
c.execute('CREATE INDEX IF NOT EXISTS idx_snap_app ON snapshots(app)')
|
|
c.execute('''CREATE TABLE IF NOT EXISTS daily_rollup (
|
|
day TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
|
|
net_rx INTEGER, net_tx INTEGER,
|
|
PRIMARY KEY (day, app)
|
|
)''')
|
|
c.execute('''CREATE TABLE IF NOT EXISTS weekly_rollup (
|
|
week TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
|
|
net_rx INTEGER, net_tx INTEGER,
|
|
PRIMARY KEY (week, app)
|
|
)''')
|
|
c.execute('''CREATE TABLE IF NOT EXISTS monthly_rollup (
|
|
month TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
|
|
net_rx INTEGER, net_tx INTEGER,
|
|
PRIMARY KEY (month, app)
|
|
)''')
|
|
c.execute('''CREATE TABLE IF NOT EXISTS yearly_rollup (
|
|
year TEXT, app TEXT, cpu_avg REAL, mem_avg REAL,
|
|
net_rx INTEGER, net_tx INTEGER,
|
|
PRIMARY KEY (year, app)
|
|
)''')
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
def _get_process_list():
|
|
"""Get current processes: {comm: {cpu_pct, mem_mb}}"""
|
|
procs = {}
|
|
try:
|
|
result = os.popen("ps -eo comm,pcpu,rss --no-headers 2>/dev/null").read()
|
|
for line in result.strip().split('\n'):
|
|
parts = line.strip().split(None, 2)
|
|
if len(parts) >= 3:
|
|
name = parts[0]
|
|
# Normalize names: strip docker prefixes, version suffixes
|
|
if name.startswith('docker-'):
|
|
name = name[7:] # strip 'docker-'
|
|
name = re.sub(r'[-_]\d+$', '', name) # strip trailing numbers
|
|
name = re.sub(r'[-_](v?\d[\d.]*|arm.*|amd64)$', '', name)
|
|
|
|
try:
|
|
cpu = float(parts[1])
|
|
except ValueError:
|
|
cpu = 0
|
|
try:
|
|
mem_kb = int(parts[2])
|
|
except ValueError:
|
|
mem_kb = 0
|
|
|
|
if name not in procs:
|
|
procs[name] = {'cpu': 0, 'mem': 0}
|
|
procs[name]['cpu'] += cpu
|
|
procs[name]['mem'] += mem_kb
|
|
except Exception:
|
|
pass
|
|
# Convert mem from KB to MB
|
|
for v in procs.values():
|
|
v['mem'] = round(v['mem'] / 1024, 1)
|
|
return procs
|
|
|
|
|
|
def _get_docker_traffic():
|
|
"""Get per-container network: {name: {rx, tx}}"""
|
|
traffic = {}
|
|
try:
|
|
result = subprocess.run(
|
|
['docker', 'stats', '--no-stream', '--format', '{{.Name}}\t{{.NetIO}}'],
|
|
capture_output=True, text=True, timeout=15
|
|
)
|
|
for line in result.stdout.strip().split('\n'):
|
|
parts = line.strip().split('\t')
|
|
if len(parts) < 2:
|
|
continue
|
|
name = parts[0]
|
|
netio = parts[1].strip()
|
|
rx_str, tx_str = '', ''
|
|
if ' / ' in netio:
|
|
rx_str, tx_str = netio.split(' / ', 1)
|
|
|
|
def parse(s):
|
|
s = s.strip().replace(',', '')
|
|
m = re.match(r'([\d.]+)\s*([a-zA-Z]+)', s)
|
|
if not m:
|
|
return 0
|
|
v = float(m.group(1))
|
|
u = m.group(2).lower()
|
|
mul = {'b': 1, 'kb': 1000, 'kib': 1024, 'mb': 1e6,
|
|
'mib': 1048576, 'gb': 1e9, 'gib': 1073741824}
|
|
return int(v * mul.get(u, 1))
|
|
|
|
traffic[name] = {'rx': parse(rx_str), 'tx': parse(tx_str)}
|
|
except Exception:
|
|
pass
|
|
return traffic
|
|
|
|
|
|
def collect_snapshot():
|
|
"""Collect and store one snapshot of app usage."""
|
|
global _LAST_SNAPSHOT_TS
|
|
now = int(time.time())
|
|
if now - _LAST_SNAPSHOT_TS < _SNAPSHOT_INTERVAL:
|
|
return
|
|
_LAST_SNAPSHOT_TS = now
|
|
|
|
procs = _get_process_list()
|
|
dockers = _get_docker_traffic()
|
|
|
|
conn = _get_db()
|
|
c = conn.cursor()
|
|
|
|
# Process CPU + mem
|
|
for app, data in procs.items():
|
|
net_rx = dockers.get(app, {}).get('rx', 0)
|
|
net_tx = dockers.get(app, {}).get('tx', 0)
|
|
c.execute(
|
|
'INSERT INTO snapshots VALUES (?,?,?,?,?,?)',
|
|
(now, app, data['cpu'], data['mem'], net_rx, net_tx)
|
|
)
|
|
|
|
# Docker-only apps (no matching process)
|
|
for app, data in dockers.items():
|
|
if app not in procs:
|
|
c.execute(
|
|
'INSERT INTO snapshots VALUES (?,?,?,?,?,?)',
|
|
(now, app, 0, 0, data['rx'], data['tx'])
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
# Cleanup old snapshots (keep last 7 days of raw data)
|
|
c.execute('DELETE FROM snapshots WHERE ts < ?', (now - 7 * 86400,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
# Roll up daily/weekly/monthly/yearly
|
|
_rollup_all()
|
|
|
|
|
|
def _rollup_all():
|
|
"""Roll up snapshots into daily/weekly/monthly/yearly aggregates."""
|
|
conn = _get_db()
|
|
c = conn.cursor()
|
|
now = datetime.now()
|
|
today = now.strftime('%Y-%m-%d')
|
|
this_week = now.strftime('%G-W%V') # ISO week
|
|
this_month = now.strftime('%Y-%m')
|
|
this_year = now.strftime('%Y')
|
|
|
|
periods = [
|
|
('daily_rollup', 'day', today),
|
|
('weekly_rollup', 'week', this_week),
|
|
('monthly_rollup', 'month', this_month),
|
|
('yearly_rollup', 'year', this_year),
|
|
]
|
|
|
|
for table, col, period_val in periods:
|
|
# Aggregate from snapshots (last 24h for daily, etc.)
|
|
cutoff = int(time.time()) - 86400 # last 24h for all rollups
|
|
c.execute(f'''
|
|
INSERT OR REPLACE INTO {table} ({col}, app, cpu_avg, mem_avg, net_rx, net_tx)
|
|
SELECT ?, app,
|
|
ROUND(AVG(cpu_pct), 1), ROUND(AVG(mem_mb), 1),
|
|
MAX(net_rx), MAX(net_tx)
|
|
FROM snapshots
|
|
WHERE ts >= ?
|
|
GROUP BY app
|
|
ORDER BY AVG(cpu_pct) DESC
|
|
''', (period_val, cutoff))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def get_app_ranking(period='total', metric='cpu', limit=15):
|
|
"""
|
|
Get app usage ranking.
|
|
period: 'day', 'week', 'month', 'year', 'total'
|
|
metric: 'cpu', 'mem', 'net'
|
|
"""
|
|
now = datetime.now()
|
|
today = now.strftime('%Y-%m-%d')
|
|
this_week = now.strftime('%G-W%V')
|
|
this_month = now.strftime('%Y-%m')
|
|
this_year = now.strftime('%Y')
|
|
|
|
conn = _get_db()
|
|
c = conn.cursor()
|
|
|
|
if period == 'day':
|
|
table = 'daily_rollup'
|
|
col = 'day'
|
|
val = today
|
|
elif period == 'week':
|
|
table = 'weekly_rollup'
|
|
col = 'week'
|
|
val = this_week
|
|
elif period == 'month':
|
|
table = 'monthly_rollup'
|
|
col = 'month'
|
|
val = this_month
|
|
elif period == 'year':
|
|
table = 'yearly_rollup'
|
|
col = 'year'
|
|
val = this_year
|
|
else: # total - from snapshots aggregate all time
|
|
table = 'snapshots'
|
|
col = None
|
|
val = None
|
|
|
|
if metric == 'cpu':
|
|
order_col = 'cpu_avg'
|
|
elif metric == 'mem':
|
|
order_col = 'mem_avg'
|
|
else: # net
|
|
order_col = '(net_rx + net_tx)'
|
|
|
|
if period == 'total':
|
|
c.execute(f'''
|
|
SELECT app,
|
|
ROUND(AVG(cpu_pct), 1) as cpu_avg,
|
|
ROUND(AVG(mem_mb), 1) as mem_avg,
|
|
MAX(net_rx) as net_rx,
|
|
MAX(net_tx) as net_tx
|
|
FROM snapshots
|
|
GROUP BY app
|
|
ORDER BY {order_col} DESC
|
|
LIMIT ?
|
|
''', (limit,))
|
|
else:
|
|
c.execute(f'''
|
|
SELECT app, cpu_avg, mem_avg, net_rx, net_tx
|
|
FROM {table}
|
|
WHERE {col} = ?
|
|
ORDER BY {order_col} DESC
|
|
LIMIT ?
|
|
''', (val, limit))
|
|
|
|
rows = c.fetchall()
|
|
conn.close()
|
|
|
|
result = []
|
|
for row in rows:
|
|
app, cpu, mem, rx, tx = row
|
|
result.append({
|
|
'app': app,
|
|
'cpu': cpu or 0,
|
|
'mem': mem or 0,
|
|
'net_rx': rx or 0,
|
|
'net_tx': tx or 0,
|
|
'net_total': (rx or 0) + (tx or 0),
|
|
})
|
|
return result
|
|
|
|
|
|
def get_combined_ranking(period='total', limit=15):
|
|
"""Combined ranking: weighted score across CPU + mem + net."""
|
|
apps = {}
|
|
periods = ['day', 'week', 'month', 'year', 'total']
|
|
target = period if period != 'total' else 'total'
|
|
|
|
for metric in ['cpu', 'mem', 'net']:
|
|
ranking = get_app_ranking(target, metric, limit=50)
|
|
total_score = max((r.get('cpu', 0) if metric == 'cpu' else
|
|
r.get('mem', 0) if metric == 'mem' else
|
|
r.get('net_total', 0)) for r in ranking) if ranking else 1
|
|
if total_score == 0:
|
|
total_score = 1
|
|
for r in ranking:
|
|
app = r['app']
|
|
if app not in apps:
|
|
apps[app] = {'app': app, 'cpu': 0, 'mem': 0, 'net_rx': 0, 'net_tx': 0, 'net_total': 0, 'score': 0}
|
|
apps[app]['cpu'] = max(apps[app]['cpu'], r.get('cpu', 0))
|
|
apps[app]['mem'] = max(apps[app]['mem'], r.get('mem', 0))
|
|
apps[app]['net_rx'] = max(apps[app]['net_rx'], r.get('net_rx', 0))
|
|
apps[app]['net_tx'] = max(apps[app]['net_tx'], r.get('net_tx', 0))
|
|
apps[app]['net_total'] = max(apps[app]['net_total'], r.get('net_total', 0))
|
|
# Weighted score
|
|
val = (r.get('cpu', 0) if metric == 'cpu' else
|
|
r.get('mem', 0) if metric == 'mem' else
|
|
r.get('net_total', 0))
|
|
apps[app]['score'] += val / total_score
|
|
|
|
# Sort by score
|
|
result = sorted(apps.values(), key=lambda x: x['score'], reverse=True)[:limit]
|
|
return result
|
|
|
|
|
|
if __name__ == '__main__':
|
|
collect_snapshot()
|
|
print("=== CPU Ranking (total) ===")
|
|
for r in get_app_ranking('total', 'cpu', 10):
|
|
print(f" {r['app']:30s} CPU:{r['cpu']:6.1f}% MEM:{r['mem']:8.1f}MB")
|
|
print("\n=== Combined Ranking ===")
|
|
for r in get_combined_ranking('total', 10):
|
|
print(f" {r['app']:30s} score:{r['score']:5.2f} CPU:{r['cpu']:5.1f}% MEM:{r['mem']:7.1f}MB net:{r['net_total']}")
|