#!/usr/bin/env python3 import csv import html 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, urlencode, urlparse from zoneinfo import ZoneInfo 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): with urllib.request.urlopen(u, timeout=30) as r: 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 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 safe_slug(value): text = str(value or "").strip().lower() slug = [] for char in text: if char.isalnum(): slug.append(char) else: slug.append("-") normalized = "".join(slug).strip("-") while "--" in normalized: normalized = normalized.replace("--", "-") return normalized or "user" 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 get_report_bounds(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) end_exclusive = end + timedelta(seconds=1) return { "start_local": start_local, "end_local": end_local, "start": start, "end": end, "end_exclusive": end_exclusive, } 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): state = str(data.get("state") or "").strip().lower() if isinstance(data.get("active"), bool) and data.get("active"): return True if ("актив" in state) or (state == "active"): return True if state == "unknown": try: sid = int(data.get("sessionId")) except Exception: sid = -1 user = str(data.get("username") or "").strip() session_name = str(data.get("sessionName") or "").strip().lower() if sid > 0 and user and (not _is_machine_user(user)) and (session_name.startswith("rdp-") or session_name == "console"): return True return False 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 [] 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 _collect_user_rows(events, start, end, host): end_exclusive = end + timedelta(seconds=1) by_user = {} by_identity = {} for event in events: ts = pts(event.get("timestamp")) if ts < start or ts > end: continue data = event.get("data") or {} username = str(data.get("username") or "").strip() if not username: continue 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_exclusive) if interval_end > interval_start: row["intervals"].append((interval_start, interval_end)) return by_user def aggregate_rows(events, start, end, host): by_user = _collect_user_rows(events, start, end, host) 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 aggregate_hourly_rows(events, start, end, host): by_user = _collect_user_rows(events, start, end, host) rows = [] for username in sorted(by_user): row = by_user[username] merged = _merge_intervals(row["intervals"]) per_bucket = {} for interval_start, interval_end in merged: cursor = interval_start while cursor < interval_end: bucket_local = cursor.astimezone(REPORT_TZ).replace(minute=0, second=0, microsecond=0) bucket_start = bucket_local.astimezone(timezone.utc) bucket_end = (bucket_local + timedelta(hours=1)).astimezone(timezone.utc) overlap_start = max(interval_start, bucket_start) overlap_end = min(interval_end, bucket_end) if overlap_end > overlap_start: key = bucket_start per_bucket[key] = per_bucket.get(key, 0) + int((overlap_end - overlap_start).total_seconds()) cursor = bucket_end for bucket_start in sorted(per_bucket): active_seconds = per_bucket[bucket_start] if active_seconds <= 0: continue bucket_local = bucket_start.astimezone(REPORT_TZ) rows.append( { "user": row["user"], "user_id": row["user_id"], "bucket_start_utc": to_iso_utc(bucket_start), "bucket_start_local": bucket_local.isoformat(), "report_date": bucket_local.date().isoformat(), "hour_local": bucket_local.strftime("%H:00"), "active_seconds": active_seconds, "active_hhmm": hhmm(active_seconds), } ) return rows def fetch_events_for_date(host, report_date): bounds = get_report_bounds(report_date) 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 bounds, [] 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 bounds, [] return bounds, events def build_report_summary(rows): if not rows: return { "users_count": 0, "total_active_seconds": 0, "total_active_hhmm": "00:00", "first_activity": "", "last_activity": "", "top_user": "", "top_user_active_hhmm": "00:00", } total_active_seconds = sum(int(row.get("active_seconds", 0) or 0) for row in rows) first_values = [row.get("first_activity") for row in rows if row.get("first_activity")] last_values = [row.get("last_activity") for row in rows if row.get("last_activity")] top_row = max(rows, key=lambda row: int(row.get("active_seconds", 0) or 0)) return { "users_count": len(rows), "total_active_seconds": total_active_seconds, "total_active_hhmm": hhmm(total_active_seconds), "first_activity": min(first_values) if first_values else "", "last_activity": max(last_values) if last_values else "", "top_user": top_row.get("user", ""), "top_user_active_hhmm": top_row.get("active_hhmm", "00:00"), } def report_for_date(host, report_date): bounds, events = fetch_events_for_date(host, report_date) return aggregate_rows(events, bounds["start"], bounds["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 = 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 "" summary = build_report_summary(rows) today_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "today"}) yesterday_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "yesterday"}) csv_url = "/reports/worktime/today?" + urlencode({"format": "csv", "host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) json_url = "/reports/worktime/today?" + urlencode({"host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) form_action = "/reports/worktime/today" cards = [ ("Пользователи", str(summary["users_count"])), ("Активное время", summary["total_active_hhmm"]), ("Лидер дня", f"{summary['top_user']} · {summary['top_user_active_hhmm']}" if summary["top_user"] else "н/д"), ("Диапазон", f"{summary['first_activity']} -> {summary['last_activity']}" if summary["first_activity"] else "нет активности"), ] trs = [] detail_cards = [] for row in rows: user_slug = safe_slug(row["user"]) active_seconds = int(row.get("active_seconds", 0) or 0) utilization = 0.0 day_total = 24 * 3600 if day_total > 0: utilization = round((active_seconds / day_total) * 100.0, 2) trs.append( "
| Пользователь | Учётная запись | Активно | Активно, сек | Начало активности | Конец активности | Простой, сек | Сессии | Сэмплы |
|---|