fix(worktime): restore real per-user RDP report pipeline

This commit is contained in:
igor04091968
2026-05-15 00:37:15 +03:00
parent 1399eaf605
commit 6c41717ef9
8 changed files with 504 additions and 104 deletions
+1
View File
@@ -15,6 +15,7 @@ AW_SERVER_GROUP=activitywatch
# Worktime API Configuration
AW_WORKTIME_REPORT_BASE=http://10.10.10.13:5610
AW_WORKTIME_TZ=Europe/Moscow
AW_SERVER_URL=http://127.0.0.1:5600
# DLP IOC Configuration
AW_DLP_IOC_DIR=/opt/activitywatch/dlp-ioc/output
+256 -66
View File
@@ -1,16 +1,35 @@
#!/usr/bin/env python3
from http.server import BaseHTTPRequestHandler, HTTPServer
import csv
import io
import importlib.util
import json
import os
import sys
import urllib.request
from datetime import datetime, timezone, timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from zoneinfo import ZoneInfo
AW = "http://127.0.0.1:5600/api/0"
def build_aw_api_base(raw_url):
url = (raw_url or "http://127.0.0.1:5600").strip().rstrip("/")
if url.endswith("/api/0"):
return url
return url + "/api/0"
AW_SERVER_URL = os.environ.get("AW_SERVER_URL", "http://127.0.0.1:5600")
AW = build_aw_api_base(AW_SERVER_URL)
REPORT_TZ = ZoneInfo(os.environ.get("AW_WORKTIME_TZ", "Europe/Moscow"))
IOC_DIR = os.environ.get("AW_DLP_IOC_DIR", "/opt/activitywatch/dlp-ioc/output")
DEFAULT_HOST = os.environ.get("AW_WORKTIME_HOST", "SHARKON2025").strip() or "SHARKON2025"
DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMPLE_SECONDS", "30")))
MAX_SAMPLE_SECONDS = max(DEFAULT_SAMPLE_SECONDS, float(os.environ.get("AW_WORKTIME_MAX_SAMPLE_SECONDS", "300")))
LISTEN_HOST = os.environ.get("AW_WORKTIME_LISTEN_HOST", "0.0.0.0")
LISTEN_PORT = int(os.environ.get("AW_WORKTIME_PORT", "5610"))
MODULE_PATH = Path(__file__).resolve()
def get(u):
@@ -18,24 +37,64 @@ def get(u):
return json.loads(r.read().decode())
def log_warning(message):
print(f"[aw-worktime-api] {message}", file=sys.stderr, flush=True)
def pts(s):
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
def _is_machine_user(user: str) -> bool:
def to_iso_utc(dt):
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def hhmm(total_seconds):
total_seconds = max(0, int(total_seconds))
return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60)
def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS):
try:
seconds = float(value)
except Exception:
seconds = float(fallback)
if seconds <= 0:
seconds = float(fallback)
return min(seconds, MAX_SAMPLE_SECONDS)
def resolve_host(request_host=None):
host = (request_host or DEFAULT_HOST).strip()
if not host:
host = DEFAULT_HOST
return host
def get_sessions_bucket_id(host):
return f"aw-worktime-sessions_{resolve_host(host)}"
def resolve_report_date(day=None, date_text=None):
now_local = datetime.now(REPORT_TZ)
if date_text:
return datetime.strptime(date_text, "%Y-%m-%d").date()
if day == "yesterday":
return (now_local - timedelta(days=1)).date()
return now_local.date()
def _is_machine_user(user: str):
u = (user or "").strip().lower()
return u.endswith("$") or u in {"system", "localservice", "networkservice"}
def _is_active_sample(data: dict) -> bool:
def _is_active_sample(data: dict):
state = str(data.get("state") or "").strip().lower()
if isinstance(data.get("active"), bool):
if data.get("active"):
return True
if isinstance(data.get("active"), bool) and data.get("active"):
return True
if ("актив" in state) or (state == "active"):
return True
# query user can intermittently return "Unknown" on RDP hosts; if session id is valid
# and user is not a machine/service account, treat it as activity sample.
if state == "unknown":
try:
sid = int(data.get("sessionId"))
@@ -48,69 +107,178 @@ def _is_active_sample(data: dict) -> bool:
return False
def report_today():
now_local = datetime.now(REPORT_TZ)
start_local = datetime(now_local.year, now_local.month, now_local.day, tzinfo=REPORT_TZ)
end_local = start_local + timedelta(days=1) - timedelta(seconds=1)
start = start_local.astimezone(timezone.utc)
end = end_local.astimezone(timezone.utc)
b = get(AW + "/buckets")
sb = next((k for k in b if k.startswith("aw-worktime-sessions_")), None)
if not sb:
def _normalize_user_id(data, host, username):
user_id = str(data.get("userId") or "").strip()
if user_id:
left, sep, right = user_id.partition("\\")
if sep and right:
return f"{resolve_host(host)}\\{right}"
return user_id
return f"{resolve_host(host)}\\{username}"
def _event_sample_seconds(event, next_same_session_ts=None):
data = event.get("data") or {}
for key in ("sampleSeconds", "pollSeconds"):
value = data.get(key)
try:
if float(value) > 0:
return clamp_seconds(value)
except Exception:
pass
try:
duration = float(event.get("duration") or 0.0)
except Exception:
duration = 0.0
if duration > 0:
return clamp_seconds(duration)
if next_same_session_ts is not None:
delta = (next_same_session_ts - event["_ts"]).total_seconds()
if delta > 0:
return clamp_seconds(delta)
return clamp_seconds(DEFAULT_SAMPLE_SECONDS)
def _merge_intervals(intervals):
if not intervals:
return []
ev = get(f"{AW}/buckets/{sb}/events?limit=50000")
by = {}
for e in ev:
ts = pts(e.get("timestamp"))
ordered = sorted(intervals, key=lambda item: item[0])
merged = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
if end > last_end:
merged[-1] = (last_start, end)
continue
merged.append((start, end))
return merged
def aggregate_rows(events, start, end, host):
by_user = {}
by_identity = {}
for event in events:
ts = pts(event.get("timestamp"))
if ts < start or ts > end:
continue
d = e.get("data") or {}
user = (d.get("username") or "").strip()
if not user:
data = event.get("data") or {}
username = str(data.get("username") or "").strip()
if not username:
continue
active = _is_active_sample(d)
row = by.setdefault(user, {"active": set(), "first": None, "last": None, "rows": 0})
row["rows"] += 1
if active:
second = ts.replace(microsecond=0)
row["active"].add(second)
row["first"] = second if row["first"] is None or second < row["first"] else row["first"]
row["last"] = second if row["last"] is None or second > row["last"] else row["last"]
session_id = str(data.get("sessionId") or "").strip() or "unknown"
event_copy = {
"_ts": ts,
"data": data,
"duration": event.get("duration"),
}
by_identity.setdefault((username, session_id), []).append(event_copy)
for (username, session_id), samples in by_identity.items():
ordered = sorted(samples, key=lambda item: item["_ts"])
for idx, sample in enumerate(ordered):
data = sample["data"]
active = _is_active_sample(data)
next_ts = ordered[idx + 1]["_ts"] if idx + 1 < len(ordered) else None
sample_seconds = _event_sample_seconds(sample, next_ts)
row = by_user.setdefault(
username,
{
"user": username,
"user_id": _normalize_user_id(data, host, username),
"samples_count": 0,
"active_samples": 0,
"session_ids": set(),
"intervals": [],
},
)
row["samples_count"] += 1
row["session_ids"].add(session_id)
if active:
row["active_samples"] += 1
interval_start = sample["_ts"]
interval_end = min(sample["_ts"] + timedelta(seconds=sample_seconds), end + timedelta(seconds=1))
if interval_end > interval_start:
row["intervals"].append((interval_start, interval_end))
rows = []
full = int((end_local - start_local).total_seconds())
for user in sorted(by):
row = by[user]
active_seconds = len(row["active"])
rows.append({
"user": user,
"active_seconds": active_seconds,
"active_hhmm": "%02d:%02d" % (active_seconds // 3600, (active_seconds % 3600) // 60),
"first_activity": row["first"].isoformat().replace("+00:00", "Z") if row["first"] else "",
"last_activity": row["last"].isoformat().replace("+00:00", "Z") if row["last"] else "",
"idle_seconds": max(0, full - active_seconds),
"sessions_count": row["rows"],
})
full_range = int((end - start).total_seconds()) + 1
for username in sorted(by_user):
row = by_user[username]
merged = _merge_intervals(row["intervals"])
active_seconds = int(sum((end_dt - start_dt).total_seconds() for start_dt, end_dt in merged))
active_seconds = min(active_seconds, full_range)
first_activity = to_iso_utc(merged[0][0]) if merged else ""
last_activity = to_iso_utc(merged[-1][1]) if merged else ""
rows.append(
{
"user": row["user"],
"user_id": row["user_id"],
"active_seconds": active_seconds,
"active_hhmm": hhmm(active_seconds),
"first_activity": first_activity,
"last_activity": last_activity,
"idle_seconds": max(0, full_range - active_seconds),
"sessions_count": len(row["session_ids"]),
"samples_count": row["samples_count"],
"active_samples": row["active_samples"],
}
)
return rows
def render_html(rows):
def report_for_date(host, report_date):
start_local = datetime(report_date.year, report_date.month, report_date.day, tzinfo=REPORT_TZ)
end_local = start_local + timedelta(days=1) - timedelta(seconds=1)
start = start_local.astimezone(timezone.utc)
end = end_local.astimezone(timezone.utc)
bucket_id = get_sessions_bucket_id(host)
try:
get(f"{AW}/buckets/{bucket_id}")
except Exception:
log_warning(f"bucket lookup failed for host={host} bucket={bucket_id} aw_base={AW}")
return []
try:
events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000")
except Exception:
log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}")
return []
return aggregate_rows(events, start, end, host)
def report_today(host):
return report_for_date(host, resolve_report_date())
def report_for_date_fresh(host, report_date):
spec = importlib.util.spec_from_file_location("aw_worktime_runtime", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.report_for_date(host, report_date)
def render_html(rows, host, report_date, selected_day=None):
generated = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
date_local = datetime.now(REPORT_TZ).strftime("%Y-%m-%d")
date_local = report_date.strftime("%Y-%m-%d")
day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else ""
date_query = f"&date={date_local}" if not day_query else ""
trs = []
for row in rows:
trs.append(
"<tr>"
f"<td>{row['user']}</td>"
f"<td>{row['active_hhmm']}</td>"
f"<td>{row['user_id']}</td>"
f"<td class='good'>{row['active_hhmm']}</td>"
f"<td>{row['active_seconds']}</td>"
f"<td>{row['first_activity']}</td>"
f"<td>{row['last_activity']}</td>"
f"<td>{row['idle_seconds']}</td>"
f"<td>{row['sessions_count']}</td>"
f"<td>{row['samples_count']}</td>"
"</tr>"
)
if not trs:
trs.append('<tr><td colspan="7">No data for today yet.</td></tr>')
trs.append('<tr><td colspan="9">No data for today yet.</td></tr>')
return f"""<!doctype html>
<html lang="en">
<head>
@@ -126,7 +294,6 @@ def render_html(rows):
--text: #0f172a;
--muted: #475569;
--accent: #0f766e;
--accent-2: #1d4ed8;
}}
* {{ box-sizing: border-box; }}
body {{
@@ -138,7 +305,7 @@ def render_html(rows):
radial-gradient(circle at top right, rgba(15,118,110,.10), transparent 24%),
var(--bg);
}}
.wrap {{ max-width: 1180px; margin: 0 auto; padding: 24px; }}
.wrap {{ max-width: 1340px; margin: 0 auto; padding: 24px; }}
.hero {{
background: linear-gradient(135deg, #0f172a, #1e293b 58%, #0f766e);
color: #fff;
@@ -169,14 +336,12 @@ def render_html(rows):
th, td {{ padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; }}
th {{ background: #eef4fb; color: var(--muted); font-weight: 600; position: sticky; top: 0; }}
tr:nth-child(even) td {{ background: rgba(148,163,184,.06); }}
.num {{ font-variant-numeric: tabular-nums; }}
.good {{ color: var(--accent); font-weight: 700; }}
.muted {{ color: var(--muted); }}
@media (max-width: 900px) {{
.wrap {{ padding: 14px; }}
.hero h1 {{ font-size: 22px; }}
.card {{ overflow-x: auto; }}
table {{ min-width: 820px; }}
table {{ min-width: 1080px; }}
}}
</style>
</head>
@@ -184,10 +349,12 @@ def render_html(rows):
<div class="wrap">
<section class="hero">
<h1>RDP Worktime Report</h1>
<div class="meta">Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}</div>
<div class="meta">Host: {resolve_host(host)} · Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}</div>
<div class="actions">
<a href="/reports/worktime/today?format=csv">Download CSV</a>
<a href="/reports/worktime/today">View JSON</a>
<a href="/reports/worktime/today?format=html&host={resolve_host(host)}&day=today">Today</a>
<a href="/reports/worktime/today?format=html&host={resolve_host(host)}&day=yesterday">Yesterday</a>
<a href="/reports/worktime/today?format=csv&host={resolve_host(host)}{day_query}{date_query}">Download CSV</a>
<a href="/reports/worktime/today?host={resolve_host(host)}{day_query}{date_query}">View JSON</a>
</div>
</section>
<section class="card">
@@ -195,11 +362,13 @@ def render_html(rows):
<thead>
<tr>
<th>User</th>
<th>User ID</th>
<th>Active</th>
<th>Active sec</th>
<th>First activity</th>
<th>Last activity</th>
<th>Idle sec</th>
<th>Sessions</th>
<th>Samples</th>
</tr>
</thead>
@@ -215,8 +384,9 @@ def render_html(rows):
class H(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/dlp-ioc/"):
name = self.path.split("?", 1)[0].rsplit("/", 1)[-1]
parsed = urlparse(self.path)
if parsed.path.startswith("/dlp-ioc/"):
name = parsed.path.rsplit("/", 1)[-1]
if name not in {"ioc_blacklist.json", "ioc_blacklist.csv", "ioc_blacklist.sql"}:
self.send_response(404)
self.end_headers()
@@ -241,28 +411,38 @@ class H(BaseHTTPRequestHandler):
self.wfile.write(data)
return
if not self.path.startswith("/reports/worktime/today"):
if parsed.path != "/reports/worktime/today":
self.send_response(404)
self.end_headers()
return
params = parse_qs(parsed.query, keep_blank_values=False)
fmt = "json"
if "format=csv" in self.path:
if params.get("format", ["json"])[0] == "csv":
fmt = "csv"
elif "format=html" in self.path:
elif params.get("format", ["json"])[0] == "html":
fmt = "html"
rows = report_today()
host = resolve_host(params.get("host", [DEFAULT_HOST])[0])
day = params.get("day", ["today"])[0]
date_text = params.get("date", [None])[0]
report_date = resolve_report_date(day=day, date_text=date_text)
rows = report_for_date_fresh(host, report_date)
if fmt == "csv":
out = io.StringIO()
writer = csv.DictWriter(
out,
fieldnames=[
"user",
"user_id",
"active_seconds",
"active_hhmm",
"first_activity",
"last_activity",
"idle_seconds",
"sessions_count",
"samples_count",
"active_samples",
],
)
writer.writeheader()
@@ -274,17 +454,22 @@ class H(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(data)
return
if fmt == "html":
data = render_html(rows).encode("utf-8")
data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
obj = {
"generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
"report_timezone": str(REPORT_TZ),
"host": host,
"report_date": report_date.isoformat(),
"bucket_id": get_sessions_bucket_id(host),
"rows": rows,
}
data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8")
@@ -295,4 +480,9 @@ class H(BaseHTTPRequestHandler):
self.wfile.write(data)
HTTPServer(("0.0.0.0", 5610), H).serve_forever()
def main():
HTTPServer((LISTEN_HOST, LISTEN_PORT), H).serve_forever()
if __name__ == "__main__":
main()
+14 -6
View File
@@ -1,14 +1,22 @@
(function () {
var reportBase = "__AW_WORKTIME_REPORT_BASE__";
var reportUrl = reportBase + "/reports/worktime/today?format=html";
function defaultDayQuery() {
var now = new Date();
return now.getHours() < 6 ? "day=yesterday" : "day=today";
}
var dayQuery = defaultDayQuery();
var htmlUrl = reportBase + "/reports/worktime/today?format=html&" + dayQuery;
var csvUrl = reportBase + "/reports/worktime/today?format=csv&" + dayQuery;
var jsonUrl = reportBase + "/reports/worktime/today?" + dayQuery;
var existing = document.getElementById("aw-report-links");
if (!existing) return;
existing.innerHTML =
'RDP report: ' +
'<a href="' + reportUrl + '" style="color:#fcd34d" target="_blank">HTML</a> | ' +
'<a href="' + reportBase + '/reports/worktime/today?format=csv" style="color:#7dd3fc" target="_blank">CSV</a> | ' +
'<a href="' + reportBase + '/reports/worktime/today" style="color:#86efac" target="_blank">JSON</a> | ' +
'<a href="' + htmlUrl + '" style="color:#fcd34d" target="_blank">HTML</a> | ' +
'<a href="' + csvUrl + '" style="color:#7dd3fc" target="_blank">CSV</a> | ' +
'<a href="' + jsonUrl + '" style="color:#86efac" target="_blank">JSON</a> | ' +
'<a href="#" id="aw-report-toggle" style="color:#f9fafb">Panel</a>';
var panel = document.createElement("div");
@@ -32,10 +40,10 @@
'<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:#0f172a;color:#fff;font:600 13px/1.2 sans-serif">' +
'<div>RDP Worktime Report</div>' +
'<div style="display:flex;gap:12px;align-items:center">' +
'<a href="' + reportUrl + '" target="_blank" style="color:#93c5fd;text-decoration:none">Open</a>' +
'<a href="' + htmlUrl + '" target="_blank" style="color:#93c5fd;text-decoration:none">Open</a>' +
'<a href="#" id="aw-report-close" style="color:#fff;text-decoration:none">Close</a>' +
"</div></div>" +
'<iframe src="' + reportUrl + '" title="RDP Worktime Report" style="border:0;width:100%;height:calc(100% - 42px);background:#fff"></iframe>';
'<iframe src="' + htmlUrl + '" title="RDP Worktime Report" style="border:0;width:100%;height:calc(100% - 42px);background:#fff"></iframe>';
document.body.appendChild(panel);
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
import importlib.util
from datetime import datetime, timezone
from pathlib import Path
MODULE_PATH = Path(__file__).with_name("aw-worktime-api.py")
SPEC = importlib.util.spec_from_file_location("aw_worktime_api", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
def _event(ts, username, session_id, active, **extra):
data = {
"username": username,
"userId": f"WORKGROUP\\{username}",
"sessionId": session_id,
"state": "Активно" if active else "Диск",
"active": active,
}
data.update(extra)
return {"timestamp": ts, "duration": 0.0, "data": data}
def test_aggregate_rows_uses_sample_seconds_and_merges_overlap():
start = datetime(2026, 5, 14, 6, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 14, 6, 59, 59, tzinfo=timezone.utc)
rows = MODULE.aggregate_rows(
[
_event("2026-05-14T06:00:00Z", "user5", 4, True, sampleSeconds=30),
_event("2026-05-14T06:00:30Z", "user5", 4, True, sampleSeconds=30),
_event("2026-05-14T06:00:15Z", "user5", 5, True, sampleSeconds=30),
_event("2026-05-14T06:01:00Z", "user5", 4, False, sampleSeconds=30),
],
start,
end,
"SHARKON2025",
)
assert len(rows) == 1
row = rows[0]
assert row["user"] == "user5"
assert row["user_id"] == "SHARKON2025\\user5"
assert row["active_seconds"] == 60
assert row["active_hhmm"] == "00:01"
assert row["sessions_count"] == 2
assert row["samples_count"] == 4
assert row["active_samples"] == 3
assert row["first_activity"] == "2026-05-14T06:00:00Z"
assert row["last_activity"] == "2026-05-14T06:01:00Z"
def test_aggregate_rows_falls_back_to_next_sample_delta():
start = datetime(2026, 5, 14, 7, 0, 0, tzinfo=timezone.utc)
end = datetime(2026, 5, 14, 7, 59, 59, tzinfo=timezone.utc)
rows = MODULE.aggregate_rows(
[
_event("2026-05-14T07:00:00Z", "user1", 3, True),
_event("2026-05-14T07:00:05Z", "user1", 3, True),
_event("2026-05-14T07:00:10Z", "user1", 3, False),
],
start,
end,
"SHARKON2025",
)
row = rows[0]
assert row["active_seconds"] == 10
assert row["active_hhmm"] == "00:00"
def test_build_aw_api_base_accepts_root_and_api_urls():
assert MODULE.build_aw_api_base("http://127.0.0.1:5600") == "http://127.0.0.1:5600/api/0"
assert MODULE.build_aw_api_base("http://127.0.0.1:5600/") == "http://127.0.0.1:5600/api/0"
assert MODULE.build_aw_api_base("http://127.0.0.1:5600/api/0") == "http://127.0.0.1:5600/api/0"