fix(worktime): restore real per-user RDP report pipeline
This commit is contained in:
@@ -310,7 +310,7 @@
|
||||
AW_DLP_CASE_DB_PATH={{ aw_dlp_case_db_path }}
|
||||
AW_DLP_COMPLIANCE_REPORT_DIR={{ aw_dlp_compliance_report_dir }}
|
||||
AW_DLP_COMPLIANCE_TEMPLATE={{ aw_dlp_compliance_template_path }}
|
||||
AW_SERVER_URL=http://127.0.0.1:5600/api/0
|
||||
AW_SERVER_URL=http://127.0.0.1:5600
|
||||
XDG_DATA_HOME={{ aw_server_data_dir }}/.local/share
|
||||
XDG_CONFIG_HOME={{ aw_server_data_dir }}/.config
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@
|
||||
AW_SERVER_LOG_DIR={{ aw_server_log_dir }}
|
||||
AW_SERVER_USER={{ aw_server_user }}
|
||||
AW_SERVER_GROUP={{ aw_server_group }}
|
||||
AW_SERVER_URL=http://127.0.0.1:{{ aw_server_port }}
|
||||
AW_WORKTIME_REPORT_BASE={{ aw_worktime_report_base }}
|
||||
AW_WORKTIME_TZ={{ aw_worktime_timezone }}
|
||||
no_log: true
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
+119
-27
@@ -5,6 +5,9 @@ DAY=""
|
||||
FROM=""
|
||||
TO=""
|
||||
AW_BASE_URL="${AW_BASE_URL:-http://10.10.10.13:5600/api/0}"
|
||||
AW_WORKTIME_HOST="${AW_WORKTIME_HOST:-SHARKON2025}"
|
||||
AW_WORKTIME_DEFAULT_SAMPLE_SECONDS="${AW_WORKTIME_DEFAULT_SAMPLE_SECONDS:-30}"
|
||||
AW_WORKTIME_MAX_SAMPLE_SECONDS="${AW_WORKTIME_MAX_SAMPLE_SECONDS:-300}"
|
||||
OUT_DIR="${OUT_DIR:-reports}"
|
||||
|
||||
usage() {
|
||||
@@ -14,6 +17,7 @@ Usage:
|
||||
$0 --from YYYY-MM-DD --to YYYY-MM-DD
|
||||
Env:
|
||||
AW_BASE_URL (default: ${AW_BASE_URL})
|
||||
AW_WORKTIME_HOST (default: ${AW_WORKTIME_HOST})
|
||||
OUT_DIR (default: ${OUT_DIR})
|
||||
EOF
|
||||
}
|
||||
@@ -50,14 +54,19 @@ mkdir -p "$OUT_DIR"
|
||||
CSV_OUT="${OUT_DIR}/rdp-worktime-${FROM}_${TO}.csv"
|
||||
JSON_OUT="${OUT_DIR}/rdp-worktime-${FROM}_${TO}.json"
|
||||
|
||||
python3 - "$AW_BASE_URL" "$FROM" "$TO" "$CSV_OUT" "$JSON_OUT" <<'PY'
|
||||
python3 - "$AW_BASE_URL" "$AW_WORKTIME_HOST" "$AW_WORKTIME_DEFAULT_SAMPLE_SECONDS" "$AW_WORKTIME_MAX_SAMPLE_SECONDS" "$FROM" "$TO" "$CSV_OUT" "$JSON_OUT" <<'PY'
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
base, from_d, to_d, csv_out, json_out = sys.argv[1:6]
|
||||
base, host, default_sample, max_sample, from_d, to_d, csv_out, json_out = sys.argv[1:9]
|
||||
base = (base or "http://10.10.10.13:5600").rstrip("/")
|
||||
if not base.endswith("/api/0"):
|
||||
base = base + "/api/0"
|
||||
default_sample = max(1.0, float(default_sample))
|
||||
max_sample = max(default_sample, float(max_sample))
|
||||
|
||||
def get_json(url: str):
|
||||
with urllib.request.urlopen(url, timeout=30) as r:
|
||||
@@ -68,21 +77,55 @@ def parse_ts(s):
|
||||
return None
|
||||
return datetime.fromisoformat(s.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
|
||||
buckets = get_json(f"{base}/buckets")
|
||||
sessions_bucket = None
|
||||
for k in buckets.keys():
|
||||
if k.startswith("aw-worktime-sessions_"):
|
||||
sessions_bucket = k
|
||||
break
|
||||
def clamp_seconds(value, fallback=default_sample):
|
||||
try:
|
||||
seconds = float(value)
|
||||
except Exception:
|
||||
seconds = float(fallback)
|
||||
if seconds <= 0:
|
||||
seconds = float(fallback)
|
||||
return min(seconds, max_sample)
|
||||
|
||||
if not sessions_bucket:
|
||||
raise SystemExit("No aw-worktime-sessions_* bucket found")
|
||||
def merge_intervals(intervals):
|
||||
if not intervals:
|
||||
return []
|
||||
intervals = sorted(intervals, key=lambda item: item[0])
|
||||
merged = [intervals[0]]
|
||||
for start, end in intervals[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 is_active(data):
|
||||
state = str(data.get("state") or "").strip().lower()
|
||||
if isinstance(data.get("active"), bool) and data.get("active"):
|
||||
return True
|
||||
return ("актив" in state) or (state == "active")
|
||||
|
||||
def normalize_user_id(data, host_name, username):
|
||||
raw = str(data.get("userId") or "").strip()
|
||||
if raw and "\\" in raw:
|
||||
_, right = raw.split("\\", 1)
|
||||
return f"{host_name}\\{right}"
|
||||
if raw:
|
||||
return raw
|
||||
return f"{host_name}\\{username}"
|
||||
|
||||
bucket_id = f"aw-worktime-sessions_{host}"
|
||||
try:
|
||||
get_json(f"{base}/buckets/{bucket_id}")
|
||||
except Exception:
|
||||
raise SystemExit(f"Bucket not found: {bucket_id}")
|
||||
|
||||
start = datetime.fromisoformat(from_d + "T00:00:00+00:00")
|
||||
end = datetime.fromisoformat(to_d + "T23:59:59+00:00")
|
||||
|
||||
ev = get_json(f"{base}/buckets/{sessions_bucket}/events?limit=20000")
|
||||
by_user = {}
|
||||
ev = get_json(f"{base}/buckets/{bucket_id}/events?limit=50000")
|
||||
by_identity = {}
|
||||
for e in ev:
|
||||
ts = parse_ts(e.get("timestamp"))
|
||||
if ts is None or ts < start or ts > end:
|
||||
@@ -91,40 +134,89 @@ for e in ev:
|
||||
user = (d.get("username") or "").strip()
|
||||
if not user:
|
||||
continue
|
||||
state = (d.get("state") or "").strip().lower()
|
||||
is_active = ("актив" in state) or (state == "active")
|
||||
rec = by_user.setdefault(user, {"active_ts": set(), "first": None, "last": None, "rows": 0})
|
||||
rec["rows"] += 1
|
||||
if is_active:
|
||||
rec["active_ts"].add(ts.replace(microsecond=0))
|
||||
rec["first"] = ts if rec["first"] is None or ts < rec["first"] else rec["first"]
|
||||
rec["last"] = ts if rec["last"] is None or ts > rec["last"] else rec["last"]
|
||||
session_id = str(d.get("sessionId") or "").strip() or "unknown"
|
||||
by_identity.setdefault((user, session_id), []).append({
|
||||
"ts": ts,
|
||||
"duration": e.get("duration"),
|
||||
"data": d,
|
||||
})
|
||||
|
||||
rows = []
|
||||
full_range = int((end - start).total_seconds())
|
||||
full_range = int((end - start).total_seconds()) + 1
|
||||
by_user = {}
|
||||
for (user, session_id), samples in by_identity.items():
|
||||
samples = sorted(samples, key=lambda item: item["ts"])
|
||||
for idx, sample in enumerate(samples):
|
||||
data = sample["data"]
|
||||
rec = by_user.setdefault(user, {
|
||||
"user": user,
|
||||
"user_id": normalize_user_id(data, host, user),
|
||||
"sessions": set(),
|
||||
"samples_count": 0,
|
||||
"active_samples": 0,
|
||||
"intervals": [],
|
||||
})
|
||||
rec["sessions"].add(session_id)
|
||||
rec["samples_count"] += 1
|
||||
if not is_active(data):
|
||||
continue
|
||||
rec["active_samples"] += 1
|
||||
sample_seconds = None
|
||||
for key in ("sampleSeconds", "pollSeconds"):
|
||||
value = data.get(key)
|
||||
try:
|
||||
if float(value) > 0:
|
||||
sample_seconds = clamp_seconds(value)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if sample_seconds is None:
|
||||
try:
|
||||
duration = float(sample.get("duration") or 0.0)
|
||||
except Exception:
|
||||
duration = 0.0
|
||||
if duration > 0:
|
||||
sample_seconds = clamp_seconds(duration)
|
||||
else:
|
||||
next_ts = samples[idx + 1]["ts"] if idx + 1 < len(samples) else None
|
||||
if next_ts is not None:
|
||||
sample_seconds = clamp_seconds((next_ts - sample["ts"]).total_seconds())
|
||||
else:
|
||||
sample_seconds = clamp_seconds(default_sample)
|
||||
interval_end = min(sample["ts"] + timedelta(seconds=sample_seconds), end + timedelta(seconds=1))
|
||||
if interval_end > sample["ts"]:
|
||||
rec["intervals"].append((sample["ts"], interval_end))
|
||||
|
||||
for user in sorted(by_user.keys()):
|
||||
rec = by_user[user]
|
||||
active = len(rec["active_ts"])
|
||||
merged = merge_intervals(rec["intervals"])
|
||||
active = int(sum((finish - begin).total_seconds() for begin, finish in merged))
|
||||
active = min(active, full_range)
|
||||
idle = max(0, full_range - active)
|
||||
rows.append({
|
||||
"user": user,
|
||||
"user": rec["user"],
|
||||
"user_id": rec["user_id"],
|
||||
"active_seconds": int(active),
|
||||
"active_hhmm": f"{int(active)//3600:02d}:{(int(active)%3600)//60:02d}",
|
||||
"first_activity": rec["first"].isoformat().replace("+00:00","Z") if rec["first"] else "",
|
||||
"last_activity": rec["last"].isoformat().replace("+00:00","Z") if rec["last"] else "",
|
||||
"first_activity": merged[0][0].isoformat().replace("+00:00","Z") if merged else "",
|
||||
"last_activity": merged[-1][1].isoformat().replace("+00:00","Z") if merged else "",
|
||||
"idle_seconds": int(idle),
|
||||
"sessions_count": rec["rows"],
|
||||
"sessions_count": len(rec["sessions"]),
|
||||
"samples_count": rec["samples_count"],
|
||||
"active_samples": rec["active_samples"],
|
||||
})
|
||||
|
||||
with open(csv_out, "w", newline="", encoding="utf-8") as f:
|
||||
w = csv.DictWriter(f, fieldnames=[
|
||||
"user","active_seconds","active_hhmm","first_activity","last_activity","idle_seconds","sessions_count"
|
||||
"user","user_id","active_seconds","active_hhmm","first_activity","last_activity","idle_seconds","sessions_count","samples_count","active_samples"
|
||||
])
|
||||
w.writeheader()
|
||||
w.writerows(rows)
|
||||
|
||||
with open(json_out, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"host": host,
|
||||
"bucket_id": bucket_id,
|
||||
"from": from_d,
|
||||
"to": to_d,
|
||||
"generated_at_utc": datetime.now(timezone.utc).isoformat().replace("+00:00","Z"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\AWatch-rus\deployment-config.json',
|
||||
[string]$Hostname,
|
||||
[int]$PollSeconds = 30
|
||||
[int]$PollSeconds = 0
|
||||
)
|
||||
|
||||
# Force UTF-8 for console I/O
|
||||
@@ -198,14 +198,46 @@ function Test-SessionIsActive {
|
||||
return ($s -match 'active') -or ($s -match '\u0430\u043a\u0442\u0438\u0432')
|
||||
}
|
||||
|
||||
function Get-CanonicalUserId {
|
||||
param(
|
||||
[pscustomobject]$Config,
|
||||
[string]$HostnameValue,
|
||||
[string]$Username
|
||||
)
|
||||
|
||||
$normalizedUser = [string]$Username
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedUser)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if ($Config -and $Config.PSObject.Properties.Name -contains 'userTasks' -and $Config.userTasks) {
|
||||
foreach ($task in @($Config.userTasks)) {
|
||||
try {
|
||||
$taskUserId = [string]$task.userId
|
||||
if ([string]::IsNullOrWhiteSpace($taskUserId)) {
|
||||
continue
|
||||
}
|
||||
$parts = $taskUserId -split '\\', 2
|
||||
if ($parts.Count -eq 2 -and $parts[1].Equals($normalizedUser, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return $taskUserId
|
||||
}
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "$HostnameValue\$normalizedUser"
|
||||
}
|
||||
|
||||
# Main
|
||||
$cfg = Get-Config -Path $ConfigPath
|
||||
$hostValue = if ($Hostname -and $Hostname.Trim()) { $Hostname.Trim() } elseif ($cfg -and $cfg.PSObject.Properties.Name -contains 'awHostname' -and -not [string]::IsNullOrWhiteSpace([string]$cfg.awHostname)) { [string]$cfg.awHostname } elseif ($cfg -and $cfg.awHostname) { [string]$cfg.awHostname } else { [string]$env:COMPUTERNAME }
|
||||
try { $apiBase = '{0}://{1}:{2}/api/0' -f [string]$cfg.server.scheme, [string]$cfg.server.host, [string]$cfg.server.port } catch { throw 'Invalid server configuration in config file.' }
|
||||
|
||||
$bucketId = 'aw-worktime-sessions_' + $hostValue
|
||||
$pulse = 120
|
||||
$sleepSec = if ($PollSeconds -gt 0) { $PollSeconds } elseif ($cfg.collector -and $cfg.collector.pollSeconds) { [int]$cfg.collector.pollSeconds } else { 30 }
|
||||
$pulse = [Math]::Max($sleepSec * 3, 30)
|
||||
|
||||
Ensure-Bucket -ApiBase $apiBase -BucketId $bucketId -HostnameValue $hostValue
|
||||
|
||||
@@ -234,16 +266,19 @@ while ($true) {
|
||||
}
|
||||
|
||||
foreach ($rec in $records) {
|
||||
$canonicalUserId = Get-CanonicalUserId -Config $cfg -HostnameValue $hostValue -Username ([string]$rec.username)
|
||||
$payloadObj = [PSCustomObject]@{
|
||||
timestamp = $now
|
||||
duration = 0
|
||||
duration = $sleepSec
|
||||
data = [PSCustomObject]@{
|
||||
username = [string]$rec.username
|
||||
userId = "${env:USERDOMAIN}\$($rec.username)"
|
||||
userId = $canonicalUserId
|
||||
sessionId = [int]$rec.sessionId
|
||||
sessionName = [string]$rec.sessionName
|
||||
state = [string]$rec.state
|
||||
active = Test-SessionIsActive -State ([string]$rec.state)
|
||||
sampleSeconds = $sleepSec
|
||||
pollSeconds = $sleepSec
|
||||
hostname = $hostValue
|
||||
source = 'worktime-session-collector'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user