diff --git a/README.md b/README.md index 95afe51..7d3b8d0 100755 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - `docs/dlp-reliability-roadmap.md` — roadmap повышения надёжности DLP-коллекторов. - `docs/powershell-analysis.md` — статический анализ работоспособности DLP PowerShell-скриптов. - `proxmox/` — шаблонные скрипты подготовки и наполнения CT на стороне Proxmox. -- `aw-server/` — установочные скрипты, env-шаблон, systemd unit и RU patch для Web UI. +- `aw-server/` — установочные скрипты, env-шаблон, systemd unit, RU patch для Web UI и server-side worktime/management report API на `:5610`. - `ansible/` — Ansible-ensemble для автоматизированного сервера (Debian/CT). - `grafana/` — version-controlled Grafana dashboard JSON для RDP/worktime, DLP/ИБ и overview-экранов. - `clickhouse-1c/` — отдельный analytics stack для **файловой 1С**: ETL, ClickHouse schema, detections, company intelligence marts/forecasting, Grafana catalog и AI Investigator contract. diff --git a/aw-server/aw-server.env.example b/aw-server/aw-server.env.example index 0c1594b..b62b87c 100755 --- a/aw-server/aw-server.env.example +++ b/aw-server/aw-server.env.example @@ -17,6 +17,8 @@ AW_SERVER_PUBLIC_HOST=aw-server AW_WORKTIME_REPORT_BASE=http://aw-server:5610 AW_WORKTIME_TZ=Europe/Moscow AW_SERVER_URL=http://127.0.0.1:5600 +AW_WORKTIME_MANAGER_ALIASES_JSON=/etc/activitywatch/worktime-manager-aliases.json +AW_WORKTIME_MANAGER_EXCLUDE_USERS= # DLP IOC Configuration AW_DLP_IOC_DIR=/opt/activitywatch/dlp-ioc/output diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index a1ad20b..bde9322 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -6,6 +6,7 @@ import importlib.util import json import os import sys +import tempfile import urllib.request from datetime import datetime, timezone, timedelta from http.server import BaseHTTPRequestHandler, HTTPServer @@ -30,7 +31,23 @@ DEFAULT_SAMPLE_SECONDS = max(1.0, float(os.environ.get("AW_WORKTIME_DEFAULT_SAMP 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")) +WORKDAY_START_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_START_HOUR", "9")) +WORKDAY_END_HOUR = int(os.environ.get("AW_WORKTIME_MANAGER_END_HOUR", "18")) +MANAGER_TARGET_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_TARGET_COVERAGE_PCT", "75")))) +MANAGER_LOW_COVERAGE_PCT = max(1, min(100, int(os.environ.get("AW_WORKTIME_MANAGER_LOW_COVERAGE_PCT", "35")))) +MANAGER_LATE_START_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_LATE_START_GRACE_MINUTES", "60"))) +MANAGER_EARLY_FINISH_GRACE_MINUTES = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_EARLY_FINISH_GRACE_MINUTES", "90"))) +MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS = max(60, int(os.environ.get("AW_WORKTIME_MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS", "900"))) +MANAGER_WEB_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_WEB_SOURCE_MAX_AGE_SECONDS", "259200"))) +MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS", "604800"))) +MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS = max(3600, int(os.environ.get("AW_WORKTIME_MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS", "172800"))) +MANAGER_TREND_DAYS = max(3, min(31, int(os.environ.get("AW_WORKTIME_MANAGER_TREND_DAYS", "7")))) +MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE_TTL_SECONDS", "120"))) +MANAGER_CACHE_DIR = Path(os.environ.get("AW_WORKTIME_MANAGER_CACHE_DIR", "/var/lib/activitywatch/worktime-cache")) +MANAGER_ALIASES_JSON = Path(os.environ.get("AW_WORKTIME_MANAGER_ALIASES_JSON", "/etc/activitywatch/worktime-manager-aliases.json")) +MANAGER_EXCLUDE_USERS = {item.strip().lower() for item in os.environ.get("AW_WORKTIME_MANAGER_EXCLUDE_USERS", "").split(",") if item.strip()} MODULE_PATH = Path(__file__).resolve() +_ALIASES_CACHE = {"mtime": None, "payload": {}} def get(u): @@ -50,11 +67,37 @@ def to_iso_utc(dt): return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") +def parse_iso_utc(value): + if not value: + return None + return pts(value) + + def hhmm(total_seconds): total_seconds = max(0, int(total_seconds)) return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) +def now_utc(): + return datetime.now(timezone.utc) + + +def age_seconds(ts, now=None): + if ts is None: + return None + ref = now or now_utc() + return max(0, int((ref - ts).total_seconds())) + + +def write_atomic_json(path, payload): + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + tmp_name = handle.name + os.replace(tmp_name, path) + + def safe_slug(value): text = str(value or "").strip().lower() slug = [] @@ -69,6 +112,83 @@ def safe_slug(value): return normalized or "user" +def _normalize_identity_key(value): + return str(value or "").strip().lower() + + +def _default_display_name(user, user_id): + base = str(user_id or user or "").strip() + if "\\" in base: + base = base.split("\\", 1)[1] + if not base: + base = str(user or "").strip() + if base and base.isascii() and base.lower() == base and any(ch.isalpha() for ch in base): + return base.upper() + return base or str(user or "").strip() + + +def load_manager_aliases(): + try: + stat = MANAGER_ALIASES_JSON.stat() + except FileNotFoundError: + _ALIASES_CACHE["mtime"] = None + _ALIASES_CACHE["payload"] = {} + return {} + mtime = stat.st_mtime + if _ALIASES_CACHE["mtime"] == mtime: + return _ALIASES_CACHE["payload"] + try: + raw = json.loads(MANAGER_ALIASES_JSON.read_text(encoding="utf-8")) + except Exception as exc: + log_warning(f"failed to load manager aliases from {MANAGER_ALIASES_JSON}: {exc}") + raw = {} + payload = {} + if isinstance(raw, dict): + users = raw.get("users", raw) + if isinstance(users, dict): + for key, value in users.items(): + norm_key = _normalize_identity_key(key) + if not norm_key: + continue + if isinstance(value, str): + payload[norm_key] = {"display_name": value} + elif isinstance(value, dict): + payload[norm_key] = dict(value) + _ALIASES_CACHE["mtime"] = mtime + _ALIASES_CACHE["payload"] = payload + return payload + + +def resolve_user_alias(user, user_id, host): + aliases = load_manager_aliases() + candidates = [ + _normalize_identity_key(user_id), + _normalize_identity_key(f"{resolve_host(host)}\\{user}"), + _normalize_identity_key(user), + ] + alias = {} + for candidate in candidates: + if candidate and candidate in aliases: + alias = dict(aliases[candidate]) + break + display_name = str(alias.get("display_name") or alias.get("name") or _default_display_name(user, user_id)).strip() + manager_owner = str(alias.get("manager") or alias.get("owner") or display_name).strip() or display_name + department = str(alias.get("department") or "").strip() + role = str(alias.get("role") or "").strip() + notes = str(alias.get("notes") or "").strip() + canonical_user_id = str(alias.get("canonical_user_id") or user_id or "").strip() + exclude = bool(alias.get("exclude")) or _normalize_identity_key(user) in MANAGER_EXCLUDE_USERS or _normalize_identity_key(display_name) in MANAGER_EXCLUDE_USERS + return { + "display_name": display_name, + "manager_owner": manager_owner, + "department": department, + "role": role, + "notes": notes, + "canonical_user_id": canonical_user_id, + "exclude": exclude, + } + + def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): try: seconds = float(value) @@ -114,6 +234,18 @@ def get_report_bounds(report_date): } +def get_workday_bounds(report_date): + start_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_START_HOUR, 0, 0, tzinfo=REPORT_TZ) + end_local = datetime(report_date.year, report_date.month, report_date.day, WORKDAY_END_HOUR, 0, 0, tzinfo=REPORT_TZ) + if end_local <= start_local: + end_local = start_local + timedelta(hours=8) + return { + "start_local": start_local, + "end_local": end_local, + "duration_seconds": int((end_local - start_local).total_seconds()), + } + + def _is_machine_user(user: str): u = (user or "").strip().lower() return u.endswith("$") or u in {"system", "localservice", "networkservice"} @@ -236,6 +368,15 @@ def _collect_user_rows(events, start, end, host): def aggregate_rows(events, start, end, host): by_user = _collect_user_rows(events, start, end, host) + return _build_rows_from_user_map(by_user, start, end, include_intervals=False) + + +def aggregate_rows_with_intervals(events, start, end, host): + by_user = _collect_user_rows(events, start, end, host) + return _build_rows_from_user_map(by_user, start, end, include_intervals=True) + + +def _build_rows_from_user_map(by_user, start, end, include_intervals): rows = [] full_range = int((end - start).total_seconds()) + 1 for username in sorted(by_user): @@ -259,6 +400,8 @@ def aggregate_rows(events, start, end, host): "active_samples": row["active_samples"], } ) + if include_intervals: + rows[-1]["_intervals"] = merged return rows @@ -345,11 +488,593 @@ def build_report_summary(rows): } +def latest_bucket_event(bucket_id): + try: + events = get(f"{AW}/buckets/{bucket_id}/events?limit=20") + except Exception: + return None + if not isinstance(events, list) or not events: + return None + valid = [item for item in events if isinstance(item, dict)] + if not valid: + return None + valid.sort(key=lambda item: item.get("timestamp") or "", reverse=True) + return valid[0] + + +def _priority_rank(priority): + return {"critical": 0, "high": 1, "medium": 2, "low": 3}.get(priority, 9) + + +def _clamp_pct(value): + return round(min(100.0, max(0.0, float(value))), 2) + + +def _action(action_id, priority, owner, deadline_hint, reason, recommended_action, *, user_id="", evidence=None): + return { + "action_id": action_id, + "priority": priority, + "owner": owner, + "user_id": user_id, + "deadline_hint": deadline_hint, + "reason": reason, + "recommended_action": recommended_action, + "evidence": evidence or {}, + } + + +def build_executive_summary(summary, actions, sources): + critical = [action for action in actions if action.get("priority") == "critical"] + high = [action for action in actions if action.get("priority") == "high"] + stale_sources = [source for source in sources if source.get("status") != "ok"] + if critical: + portfolio_state = "critical" + headline = f"Есть {len(critical)} критичных вопроса, требующих решения сегодня." + elif high: + portfolio_state = "attention" + headline = f"Критичных провалов нет, но есть {len(high)} вопроса повышенного внимания." + elif summary.get("portfolio_coverage_pct", 0.0) < MANAGER_TARGET_COVERAGE_PCT: + portfolio_state = "attention" + headline = "Покрытие ниже целевого порога, но явных критичных кейсов не найдено." + else: + portfolio_state = "stable" + headline = "Критичных отклонений не найдено, рабочий день идёт в пределах нормы." + + message_parts = [ + f"Активны {summary.get('active_users', 0)} из {summary.get('users_count', 0)} сотрудников.", + f"Покрытие рабочего окна {summary.get('portfolio_coverage_pct', 0.0)}%.", + ] + if stale_sources: + message_parts.append(f"Есть {len(stale_sources)} проблем(ы) со свежестью источников.") + message = " ".join(message_parts) + + focus_items = [] + for action in actions[:5]: + focus_items.append( + { + "priority": action["priority"], + "owner": action["owner"], + "title": action["action_id"], + "reason": action["reason"], + "recommended_action": action["recommended_action"], + } + ) + stale_items = [] + for source in stale_sources[:3]: + stale_items.append( + { + "source_id": source["source_id"], + "label": source["label"], + "status": source["status_label"], + "summary": source["summary"], + } + ) + return { + "portfolio_state": portfolio_state, + "headline": headline, + "message": message, + "focus_items": focus_items, + "stale_sources": stale_items, + } + + +def _interval_overlap_seconds(intervals, start, end): + total = 0 + first = None + last = None + for interval_start, interval_end in intervals or []: + overlap_start = max(interval_start, start) + overlap_end = min(interval_end, end) + if overlap_end <= overlap_start: + continue + seconds = int((overlap_end - overlap_start).total_seconds()) + if seconds <= 0: + continue + total += seconds + if first is None or overlap_start < first: + first = overlap_start + if last is None or overlap_end > last: + last = overlap_end + return total, first, last + + +def _source_status_label(status): + return { + "ok": "fresh", + "warn": "stale", + "fail": "missing", + }.get(status, status) + + +def _source_summary(event): + data = (event or {}).get("data") or {} + signal_type = str(data.get("signalType") or "").strip() + if signal_type == "collector_health": + return ( + f"queue={data.get('queueDepth', 0)} " + f"failures={data.get('sendFailures', 0)} " + f"flushed={data.get('eventsFlushed', 0)}" + ) + if data.get("domain"): + return f"{data.get('domain')} ({data.get('category', 'uncategorized')})" + if data.get("eventType"): + return f"{data.get('eventType')} {data.get('username', '')}".strip() + if data.get("action"): + return f"{data.get('action')} {data.get('result', '')}".strip() + if data.get("title"): + return str(data.get("title"))[:120] + if data.get("status"): + return str(data.get("status")) + if data.get("app"): + return str(data.get("app")) + return "" + + +def management_cache_path(host, report_date): + host_slug = safe_slug(host) + return MANAGER_CACHE_DIR / f"{host_slug}-{report_date.isoformat()}.json" + + +def load_management_cache(host, report_date): + if MANAGER_CACHE_TTL_SECONDS <= 0: + return None + path = management_cache_path(host, report_date) + if not path.exists(): + return None + age = age_seconds(datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc)) + if age is None or age > MANAGER_CACHE_TTL_SECONDS: + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + +def save_management_cache(host, report_date, payload): + if MANAGER_CACHE_TTL_SECONDS <= 0: + return + write_atomic_json(management_cache_path(host, report_date), payload) + + +def build_source_freshness(host): + source_specs = [ + { + "source_id": "worktime_sessions", + "label": "RDP worktime sessions", + "bucket_candidates": [f"aw-worktime-sessions_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "rdp_window", + "label": "RDP current window", + "bucket_candidates": [f"aw-rdp-window_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "rdp_afk", + "label": "RDP AFK", + "bucket_candidates": [f"aw-rdp-afk_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "watcher_window", + "label": "Local watcher window", + "bucket_candidates": [f"aw-watcher-window_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "watcher_afk", + "label": "Local watcher AFK", + "bucket_candidates": [f"aw-watcher-afk_{host}"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "file_operations", + "label": "File operations collector", + "bucket_candidates": [f"aw-file-operations_{host}", "aw-file-operations_10.10.10.13"], + "max_age_seconds": MANAGER_CRITICAL_SOURCE_MAX_AGE_SECONDS, + "required": True, + "owner": "ops", + }, + { + "source_id": "web_categories", + "label": "Browser/web categories", + "bucket_candidates": [f"aw-detmir-web-category_{host}"], + "max_age_seconds": MANAGER_WEB_SOURCE_MAX_AGE_SECONDS, + "required": False, + "owner": "ops", + }, + { + "source_id": "session_events", + "label": "Windows session events", + "bucket_candidates": [f"aw-session-events_{host}"], + "max_age_seconds": MANAGER_SESSION_SOURCE_MAX_AGE_SECONDS, + "required": False, + "owner": "ops", + }, + { + "source_id": "pve_tasks", + "label": "PVE task feed", + "bucket_candidates": ["aw-pve-task-events_pve-detmir"], + "max_age_seconds": MANAGER_INFRA_SOURCE_MAX_AGE_SECONDS, + "required": False, + "owner": "ops", + }, + ] + now = now_utc() + sources = [] + actions = [] + for spec in source_specs: + matched_bucket = "" + matched_event = None + matched_age = None + for candidate in spec["bucket_candidates"]: + event = latest_bucket_event(candidate) + if not event: + continue + ts = parse_iso_utc(event.get("timestamp")) + candidate_age = age_seconds(ts, now=now) + if matched_event is None or (candidate_age is not None and (matched_age is None or candidate_age < matched_age)): + matched_bucket = candidate + matched_event = event + matched_age = candidate_age + ts = parse_iso_utc((matched_event or {}).get("timestamp")) + age = matched_age if matched_event is not None else age_seconds(ts, now=now) + if matched_event is None: + status = "fail" if spec["required"] else "warn" + summary = "bucket missing or empty" + elif age is None: + status = "warn" + summary = "timestamp parse failed" + elif age > spec["max_age_seconds"]: + status = "fail" if spec["required"] else "warn" + summary = f"stale ({age}s)" + else: + status = "ok" + summary = f"fresh ({age}s)" + detail = { + "source_id": spec["source_id"], + "label": spec["label"], + "status": status, + "status_label": _source_status_label(status), + "bucket_id": matched_bucket or spec["bucket_candidates"][0], + "timestamp": (matched_event or {}).get("timestamp", ""), + "age_seconds": age, + "required": spec["required"], + "max_age_seconds": spec["max_age_seconds"], + "summary": summary, + "event_summary": _source_summary(matched_event), + } + sources.append(detail) + if status == "ok": + continue + priority = "critical" if spec["required"] else "medium" + actions.append( + _action( + "source_freshness_review", + priority, + spec["owner"], + "today" if spec["required"] else "3d", + f"Источник '{spec['label']}' в состоянии {detail['status_label']}: {summary}.", + "Проверить collector/service, причину отставания и подтвердить, что управленческие выводы по данным ещё надёжны.", + evidence={ + "source_id": spec["source_id"], + "bucket_id": detail["bucket_id"], + "age_seconds": age, + "required": spec["required"], + }, + ) + ) + return sources, actions + + +def _build_management_core(rows, host, report_date): + calendar_summary = build_report_summary(rows) + report_bounds = get_report_bounds(report_date) + workday = get_workday_bounds(report_date) + now_local = datetime.now(REPORT_TZ) + is_today = report_date == now_local.date() + effective_end_local = min(now_local, workday["end_local"]) if is_today else workday["end_local"] + elapsed_seconds = max(0, int((effective_end_local - workday["start_local"]).total_seconds())) + if not is_today: + elapsed_seconds = workday["duration_seconds"] + expected_seconds_per_user = min(workday["duration_seconds"], max(0, elapsed_seconds)) + target_seconds = int(expected_seconds_per_user * (MANAGER_TARGET_COVERAGE_PCT / 100.0)) + low_seconds = int(expected_seconds_per_user * (MANAGER_LOW_COVERAGE_PCT / 100.0)) + late_start_local = workday["start_local"] + timedelta(minutes=MANAGER_LATE_START_GRACE_MINUTES) + early_finish_local = workday["end_local"] - timedelta(minutes=MANAGER_EARLY_FINISH_GRACE_MINUTES) + + roster = [] + actions = [] + active_users = 0 + on_target_users = 0 + below_target_users = 0 + workday_total_active_seconds = 0 + workday_first_values = [] + workday_last_values = [] + top_workday_user = "" + top_workday_seconds = -1 + + for row in rows: + alias = resolve_user_alias(row.get("user", ""), row.get("user_id", ""), host) + if alias["exclude"]: + continue + public_row = {key: value for key, value in row.items() if key != "_intervals"} + calendar_active_seconds = int(row.get("active_seconds", 0) or 0) + intervals = row.get("_intervals") or [] + workday_active_seconds, workday_first, workday_last = _interval_overlap_seconds( + intervals, + workday["start_local"].astimezone(timezone.utc), + effective_end_local.astimezone(timezone.utc), + ) + first_activity = parse_iso_utc(row.get("first_activity")) + last_activity = parse_iso_utc(row.get("last_activity")) + first_local = first_activity.astimezone(REPORT_TZ) if first_activity else None + last_local = last_activity.astimezone(REPORT_TZ) if last_activity else None + workday_first_local = workday_first.astimezone(REPORT_TZ) if workday_first else None + workday_last_local = workday_last.astimezone(REPORT_TZ) if workday_last else None + coverage_pct = _clamp_pct((workday_active_seconds / expected_seconds_per_user) * 100.0) if expected_seconds_per_user > 0 else 0.0 + status = "ok" + if workday_active_seconds <= 0: + status = "inactive" + elif workday_active_seconds < target_seconds: + status = "below_target" + if workday_active_seconds > 0: + active_users += 1 + workday_total_active_seconds += workday_active_seconds + if workday_first_local: + workday_first_values.append(workday_first_local.isoformat()) + if workday_last_local: + workday_last_values.append(workday_last_local.isoformat()) + if workday_active_seconds >= target_seconds and workday_active_seconds > 0: + on_target_users += 1 + elif workday_active_seconds > 0: + below_target_users += 1 + if workday_active_seconds > top_workday_seconds: + top_workday_seconds = workday_active_seconds + top_workday_user = alias["display_name"] + + roster.append( + { + **public_row, + "user": alias["display_name"], + "user_original": row.get("user", ""), + "manager_owner": alias["manager_owner"], + "department": alias["department"], + "role": alias["role"], + "notes": alias["notes"], + "canonical_user_id": alias["canonical_user_id"] or row.get("user_id", ""), + "calendar_active_seconds": calendar_active_seconds, + "calendar_active_hhmm": row.get("active_hhmm", "00:00"), + "workday_active_seconds": workday_active_seconds, + "workday_active_hhmm": hhmm(workday_active_seconds), + "coverage_pct": coverage_pct, + "status": status, + "first_activity_local": first_local.isoformat() if first_local else "", + "last_activity_local": last_local.isoformat() if last_local else "", + "workday_first_activity_local": workday_first_local.isoformat() if workday_first_local else "", + "workday_last_activity_local": workday_last_local.isoformat() if workday_last_local else "", + } + ) + + owner = alias["display_name"] + user_id = alias["canonical_user_id"] or row.get("user_id", "") + evidence = { + "calendar_active_hhmm": row.get("active_hhmm", "00:00"), + "workday_active_hhmm": hhmm(workday_active_seconds), + "coverage_pct": coverage_pct, + "first_activity": row.get("first_activity", ""), + "last_activity": row.get("last_activity", ""), + "sessions_count": row.get("sessions_count", 0), + "manager_owner": alias["manager_owner"], + "department": alias["department"], + "role": alias["role"], + } + if workday_active_seconds <= 0: + actions.append( + _action( + "missing_activity", + "critical", + alias["manager_owner"], + "today", + f"За {report_date.isoformat()} у сотрудника {owner} нет подтверждённой активности в рабочем окне RDP.", + f"Проверить сотрудника {owner}: работал ли он в рабочее время, была ли потеря сбора данных или отсутствие входа в систему.", + user_id=user_id, + evidence=evidence, + ) + ) + continue + if expected_seconds_per_user > 0 and workday_active_seconds < low_seconds: + actions.append( + _action( + "low_activity_review", + "high", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже {MANAGER_LOW_COVERAGE_PCT}% от ожидаемого окна.", + f"Проверить загрузку сотрудника {owner}, задачи и фактическое присутствие в рабочем процессе.", + user_id=user_id, + evidence=evidence, + ) + ) + elif expected_seconds_per_user > 0 and workday_active_seconds < target_seconds: + actions.append( + _action( + "target_gap_review", + "medium", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} активное время в рабочем окне {hhmm(workday_active_seconds)} ниже управленческого целевого порога {MANAGER_TARGET_COVERAGE_PCT}%.", + f"Уточнить причину отклонения по сотруднику {owner} и подтвердить план работ.", + user_id=user_id, + evidence=evidence, + ) + ) + if workday_first_local and workday_first_local > late_start_local: + actions.append( + _action( + "late_start_review", + "medium", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} первая активность в рабочем окне зафиксирована поздно: {workday_first_local.strftime('%H:%M')}.", + f"Проверить причину позднего старта сотрудника {owner} и подтвердить, что это не проблема доступа или дисциплины.", + user_id=user_id, + evidence=evidence, + ) + ) + if (not is_today) and workday_last_local and workday_last_local < early_finish_local: + actions.append( + _action( + "early_finish_review", + "medium", + alias["manager_owner"], + "24h", + f"У сотрудника {owner} последняя активность в рабочем окне завершилась рано: {workday_last_local.strftime('%H:%M')}.", + f"Проверить, было ли досрочное завершение рабочего дня сотрудника {owner} согласовано и чем оно объясняется.", + user_id=user_id, + evidence=evidence, + ) + ) + + actions.sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) + inactive_users = sum(1 for row in roster if row["status"] == "inactive") + portfolio_coverage_pct = _clamp_pct((workday_total_active_seconds / (expected_seconds_per_user * len(rows))) * 100.0) if rows and expected_seconds_per_user > 0 else 0.0 + calendar_first = calendar_summary.get("first_activity", "") + calendar_last = calendar_summary.get("last_activity", "") + return { + "generated_at_utc": now_utc().isoformat().replace("+00:00", "Z"), + "host": resolve_host(host), + "report_date": report_date.isoformat(), + "report_timezone": str(REPORT_TZ), + "workday": { + "start_local": workday["start_local"].isoformat(), + "end_local": workday["end_local"].isoformat(), + "expected_seconds_per_user": expected_seconds_per_user, + "expected_hhmm_per_user": hhmm(expected_seconds_per_user), + "target_coverage_pct": MANAGER_TARGET_COVERAGE_PCT, + "low_coverage_pct": MANAGER_LOW_COVERAGE_PCT, + }, + "summary": { + **calendar_summary, + "calendar_total_active_seconds": calendar_summary["total_active_seconds"], + "calendar_total_active_hhmm": calendar_summary["total_active_hhmm"], + "calendar_first_activity": calendar_first, + "calendar_last_activity": calendar_last, + "workday_total_active_seconds": workday_total_active_seconds, + "workday_total_active_hhmm": hhmm(workday_total_active_seconds), + "workday_first_activity": min(workday_first_values) if workday_first_values else "", + "workday_last_activity": max(workday_last_values) if workday_last_values else "", + "total_active_seconds": workday_total_active_seconds, + "total_active_hhmm": hhmm(workday_total_active_seconds), + "first_activity": min(workday_first_values) if workday_first_values else "", + "last_activity": max(workday_last_values) if workday_last_values else "", + "top_user": top_workday_user, + "top_user_active_hhmm": hhmm(top_workday_seconds if top_workday_seconds > 0 else 0), + "active_users": active_users, + "inactive_users": inactive_users, + "on_target_users": on_target_users, + "below_target_users": below_target_users, + "portfolio_coverage_pct": portfolio_coverage_pct, + "actions_count": len(actions), + "critical_actions_count": sum(1 for action in actions if action["priority"] == "critical"), + "high_actions_count": sum(1 for action in actions if action["priority"] == "high"), + }, + "actions": actions, + "rows": roster, + "bucket_id": get_sessions_bucket_id(host), + "report_bounds": { + "start_utc": to_iso_utc(report_bounds["start"]), + "end_utc": to_iso_utc(report_bounds["end"]), + }, + } + + +def build_management_trend(host, anchor_date): + trend = [] + for offset in range(MANAGER_TREND_DAYS - 1, -1, -1): + current_date = anchor_date - timedelta(days=offset) + bounds, events = fetch_events_for_date(host, current_date) + rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) + payload = _build_management_core(rows, host, current_date) + summary = payload["summary"] + trend.append( + { + "report_date": current_date.isoformat(), + "users_count": summary["users_count"], + "active_users": summary["active_users"], + "inactive_users": summary["inactive_users"], + "workday_total_active_seconds": summary["workday_total_active_seconds"], + "workday_total_active_hhmm": summary["workday_total_active_hhmm"], + "portfolio_coverage_pct": summary["portfolio_coverage_pct"], + "actions_count": summary["actions_count"], + "critical_actions_count": summary["critical_actions_count"], + } + ) + return trend + + +def build_management_payload(rows, host, report_date): + payload = _build_management_core(rows, host, report_date) + source_freshness, source_actions = build_source_freshness(resolve_host(host)) + payload["sources"] = source_freshness + payload["trend"] = build_management_trend(resolve_host(host), report_date) + if source_actions: + payload["actions"].extend(source_actions) + payload["actions"].sort(key=lambda item: (_priority_rank(item["priority"]), item["owner"].lower(), item["action_id"])) + payload["summary"]["actions_count"] = len(payload["actions"]) + payload["summary"]["critical_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "critical") + payload["summary"]["high_actions_count"] = sum(1 for action in payload["actions"] if action["priority"] == "high") + payload["executive"] = build_executive_summary(payload["summary"], payload["actions"], payload["sources"]) + return payload + + 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 management_report_for_date(host, report_date): + cached = load_management_cache(host, report_date) + if cached is not None: + return cached + bounds, events = fetch_events_for_date(host, report_date) + rows = aggregate_rows_with_intervals(events, bounds["start"], bounds["end"], host) + payload = build_management_payload(rows, host, report_date) + save_management_cache(host, report_date, payload) + return payload + + def report_today(host): return report_for_date(host, resolve_report_date()) @@ -667,6 +1392,365 @@ def render_html(rows, host, report_date, selected_day=None): """ +def render_management_html(payload, selected_day=None): + summary = payload["summary"] + workday = payload["workday"] + report_date = payload["report_date"] + host = payload["host"] + executive = payload.get("executive") or {} + today_url = "/reports/worktime/management?" + urlencode({"format": "html", "host": host, "day": "today"}) + yesterday_url = "/reports/worktime/management?" + urlencode({"format": "html", "host": host, "day": "yesterday"}) + json_url = "/reports/worktime/management?" + urlencode({"host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})}) + classic_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": host, **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": report_date})}) + actions_html = [] + for action in payload["actions"]: + actions_html.append( + "" + f"{html.escape(action['priority'])}" + f"{html.escape(action['owner'])}" + f"{html.escape(action['action_id'])}" + f"{html.escape(action['deadline_hint'])}" + f"{html.escape(action['reason'])}" + f"{html.escape(action['recommended_action'])}" + "" + ) + if not actions_html: + actions_html.append("Отклонений по текущим правилам не найдено.") + + roster_html = [] + for row in payload["rows"]: + roster_html.append( + "" + f"{html.escape(row['user'])}" + f"{html.escape(row.get('canonical_user_id') or row['user_id'])}" + f"{html.escape(row.get('manager_owner') or row['user'])}" + f"{html.escape(row.get('department') or '-')}" + f"{html.escape(row['workday_active_hhmm'])}" + f"{html.escape(row['calendar_active_hhmm'])}" + f"{row['coverage_pct']}" + f"{html.escape(row['status'])}" + f"{html.escape(row.get('workday_first_activity_local') or '-')}" + f"{html.escape(row.get('workday_last_activity_local') or '-')}" + f"{row['sessions_count']}" + "" + ) + if not roster_html: + roster_html.append("За выбранную дату данных нет.") + + trend_html = [] + for row in payload.get("trend", []): + trend_html.append( + "" + f"{html.escape(row['report_date'])}" + f"{row['users_count']}" + f"{row['active_users']}" + f"{row['inactive_users']}" + f"{html.escape(row['workday_total_active_hhmm'])}" + f"{row['portfolio_coverage_pct']}" + f"{row['actions_count']}" + f"{row['critical_actions_count']}" + "" + ) + if not trend_html: + trend_html.append("Тренд пока недоступен.") + + sources_html = [] + for source in payload.get("sources", []): + sources_html.append( + "" + f"{html.escape(source['label'])}" + f"{html.escape(source['status_label'])}" + f"{html.escape(source['bucket_id'])}" + f"{html.escape(source.get('timestamp') or '-')}" + f"{html.escape(str(source.get('age_seconds')) if source.get('age_seconds') is not None else '-')}" + f"{html.escape(source.get('event_summary') or source.get('summary') or '-')}" + "" + ) + if not sources_html: + sources_html.append("Статусы источников недоступны.") + + focus_html = [] + for item in executive.get("focus_items", []): + focus_html.append( + "
" + f"
{html.escape(item['priority'])}
" + f"

{html.escape(item['title'])}

" + f"
Ответственный: {html.escape(item['owner'])}
" + f"

{html.escape(item['reason'])}

" + f"{html.escape(item['recommended_action'])}" + "
" + ) + if not focus_html: + focus_html.append("

Критичных действий нет

На текущий момент менеджерских отклонений по активным правилам не найдено.

") + + stale_html = [] + for item in executive.get("stale_sources", []): + stale_html.append( + "
  • " + f"{html.escape(item['label'])}: {html.escape(item['status'])} · {html.escape(item['summary'])}" + "
  • " + ) + stale_block = ( + "
    Проблемы со свежестью источников:
    " + ) if stale_html else "" + + cards = [ + ("Пользователи", str(summary["users_count"])), + ("Активны", str(summary["active_users"])), + ("Без активности", str(summary["inactive_users"])), + ("Ниже цели", str(summary["below_target_users"])), + ("Покрытие", f"{summary['portfolio_coverage_pct']}%"), + ("Действия", str(summary["actions_count"])), + ("Рабочее окно", summary["workday_total_active_hhmm"]), + ("Календарный день", summary["calendar_total_active_hhmm"]), + ] + + return f""" + + + + + AW-rus Управленческий отчёт по работе в RDP + + + +
    +
    +

    Управленческий отчёт по работе в RDP

    +
    Хост: {html.escape(host)} · Дата: {html.escape(report_date)} · Рабочее окно: {html.escape(workday['start_local'])} -> {html.escape(workday['end_local'])} · Сформировано UTC: {html.escape(payload['generated_at_utc'])}
    + +
    + {''.join(f"
    {html.escape(label)}{html.escape(value)}
    " for label, value in cards)} +
    +
    + Целевое покрытие: {MANAGER_TARGET_COVERAGE_PCT}% от ожидаемого рабочего окна на пользователя. + Критический провал: ниже {MANAGER_LOW_COVERAGE_PCT}% или полное отсутствие активности. + Рабочее окно считается отдельно от календарной активности, чтобы ночная работа не маскировала дневной провал. +
    +
    +
    +

    Что делать сегодня

    +
    + {html.escape(executive.get('headline') or 'Сводка недоступна')} +

    {html.escape(executive.get('message') or '')}

    + {stale_block} +
    +
    + {''.join(focus_html)} +
    +
    +
    +

    Тренд за {MANAGER_TREND_DAYS} дней

    + + + + + + + + + + + + + + + {''.join(trend_html)} + +
    ДатаПользователиАктивныБез активностиРабочее окноПокрытие, %ДействияCritical
    +
    +
    +

    Очередь действий руководителя

    + + + + + + + + + + + + + {''.join(actions_html)} + +
    ПриоритетСотрудникТипСрокПочему это важноЧто сделать
    +
    +
    +

    Покрытие по сотрудникам

    + + + + + + + + + + + + + + + + + + {''.join(roster_html)} + +
    СотрудникУчётная записьОтветственныйПодразделениеАктивно в окнеАктивно за деньПокрытие, %СтатусПервая активность в окнеПоследняя активность в окнеСессии
    +
    +
    +

    Свежесть источников данных

    + + + + + + + + + + + + + {''.join(sources_html)} + +
    ИсточникСтатусБакетПоследнее событие UTCВозраст, секКонтекст
    +
    +
    + +""" + + class H(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) @@ -696,7 +1780,7 @@ class H(BaseHTTPRequestHandler): self.wfile.write(data) return - if parsed.path != "/reports/worktime/today": + if parsed.path not in {"/reports/worktime/today", "/reports/worktime/management"}: self.send_response(404) self.end_headers() return @@ -711,9 +1795,27 @@ class H(BaseHTTPRequestHandler): 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) + is_management = parsed.path == "/reports/worktime/management" + management_payload = management_report_for_date(host, report_date) if is_management else None + rows = report_for_date_fresh(host, report_date) if not is_management else management_payload["rows"] if fmt == "csv": + if is_management: + out = io.StringIO() + writer = csv.DictWriter( + out, + fieldnames=["priority", "owner", "user_id", "action_id", "deadline_hint", "reason", "recommended_action"], + extrasaction="ignore", + ) + writer.writeheader() + writer.writerows(management_payload["actions"]) + data = out.getvalue().encode() + self.send_response(200) + self.send_header("Content-Type", "text/csv; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + return out = io.StringIO() writer = csv.DictWriter( out, @@ -741,7 +1843,10 @@ class H(BaseHTTPRequestHandler): return if fmt == "html": - data = render_html(rows, host, report_date, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") + if is_management: + data = render_management_html(management_payload, selected_day=day if day in {"today", "yesterday"} else None).encode("utf-8") + else: + 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))) @@ -749,6 +1854,16 @@ class H(BaseHTTPRequestHandler): self.wfile.write(data) return + if is_management: + obj = management_payload + data = json.dumps(obj, ensure_ascii=False, indent=2).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; 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), diff --git a/aw-server/aw-worktime-panel.js b/aw-server/aw-worktime-panel.js index a91741d..47af0e8 100644 --- a/aw-server/aw-worktime-panel.js +++ b/aw-server/aw-worktime-panel.js @@ -9,6 +9,8 @@ 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 managerHtmlUrl = reportBase + "/reports/worktime/management?format=html&" + dayQuery; + var managerJsonUrl = reportBase + "/reports/worktime/management?" + dayQuery; var existing = document.getElementById("aw-report-links"); if (!existing) return; @@ -17,6 +19,8 @@ 'HTML | ' + 'CSV | ' + 'JSON | ' + + 'Менеджмент | ' + + 'Mgmt JSON | ' + 'Панель'; var panel = document.createElement("div"); @@ -40,10 +44,11 @@ '
    ' + '
    Отчёт по работе в RDP
    ' + '
    ' + - 'Открыть' + + 'Открыть менеджмент' + + 'Открыть RDP' + 'Закрыть' + "
    " + - ''; + ''; document.body.appendChild(panel); diff --git a/aw-server/install_aw_server.sh b/aw-server/install_aw_server.sh index 6a28f35..34930d6 100755 --- a/aw-server/install_aw_server.sh +++ b/aw-server/install_aw_server.sh @@ -26,6 +26,7 @@ VIEWS_JSON="$BOOTSTRAP_DIR/settings/views-default.json" CLASSES_JSON="$BOOTSTRAP_DIR/settings/classes-worktime.json" WORKTIME_API_SRC="$BOOTSTRAP_DIR/aw-worktime-api.py" WORKTIME_API_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-api.service" +WORKTIME_ALIASES_SRC="$BOOTSTRAP_DIR/worktime-manager-aliases.example.json" WORKTIME_UI_BRIDGE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.py" WORKTIME_UI_BRIDGE_SERVICE_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.service" WORKTIME_UI_BRIDGE_TIMER_SRC="$BOOTSTRAP_DIR/aw-worktime-ui-bridge.timer" @@ -110,6 +111,14 @@ if [[ -f "$WORKTIME_API_SERVICE_SRC" ]]; then systemctl --no-pager --full status aw-worktime-api.service || true fi +if [[ -f "$WORKTIME_ALIASES_SRC" ]]; then + install -d -m 0755 /etc/activitywatch + install -m 0644 "$WORKTIME_ALIASES_SRC" /etc/activitywatch/worktime-manager-aliases.json.example + if [[ ! -f /etc/activitywatch/worktime-manager-aliases.json ]]; then + install -m 0644 "$WORKTIME_ALIASES_SRC" /etc/activitywatch/worktime-manager-aliases.json + fi +fi + if [[ -f "$WORKTIME_UI_BRIDGE_SRC" ]]; then install -m 0755 "$WORKTIME_UI_BRIDGE_SRC" /usr/local/bin/aw-worktime-ui-bridge.py fi diff --git a/aw-server/test_aw_worktime_api.py b/aw-server/test_aw_worktime_api.py index 001496b..e2ca395 100644 --- a/aw-server/test_aw_worktime_api.py +++ b/aw-server/test_aw_worktime_api.py @@ -87,3 +87,262 @@ def test_aggregate_hourly_rows_splits_interval_by_local_hour(): ) assert [row["hour_local"] for row in rows] == ["09:00", "10:00"] assert [row["active_seconds"] for row in rows] == [600, 3000] + + +def test_build_management_payload_creates_actions_for_missing_and_late_users(): + rows = [ + { + "user": "user1", + "user_id": "SHARKON2025\\user1", + "active_seconds": 0, + "active_hhmm": "00:00", + "first_activity": "", + "last_activity": "", + "idle_seconds": 86400, + "sessions_count": 1, + "samples_count": 10, + "active_samples": 0, + "_intervals": [], + }, + { + "user": "user5", + "user_id": "SHARKON2025\\user5", + "active_seconds": 5400, + "active_hhmm": "01:30", + "first_activity": "2026-05-14T08:30:00Z", + "last_activity": "2026-05-14T10:00:00Z", + "idle_seconds": 81000, + "sessions_count": 1, + "samples_count": 180, + "active_samples": 180, + "_intervals": [ + ( + datetime(2026, 5, 14, 8, 30, 0, tzinfo=timezone.utc), + datetime(2026, 5, 14, 10, 0, 0, tzinfo=timezone.utc), + ) + ], + }, + ] + payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) + assert payload["summary"]["users_count"] == 2 + assert payload["summary"]["inactive_users"] == 1 + assert payload["summary"]["actions_count"] >= 2 + action_ids = {action["action_id"] for action in payload["actions"]} + assert "missing_activity" in action_ids + assert "late_start_review" in action_ids + assert payload["actions"][0]["priority"] == "critical" + + +def test_build_management_payload_uses_workday_window_not_midnight_activity(): + rows = [ + { + "user": "администратор", + "user_id": "SHARKON2025\\Администратор", + "active_seconds": 39600, + "active_hhmm": "11:00", + "first_activity": "2026-05-14T00:00:00Z", + "last_activity": "2026-05-14T11:00:00Z", + "idle_seconds": 0, + "sessions_count": 1, + "samples_count": 100, + "active_samples": 100, + "_intervals": [ + ( + datetime(2026, 5, 14, 0, 0, 0, tzinfo=timezone.utc), + datetime(2026, 5, 14, 11, 0, 0, tzinfo=timezone.utc), + ) + ], + } + ] + payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) + roster = payload["rows"][0] + assert roster["calendar_active_hhmm"] == "11:00" + assert roster["workday_active_hhmm"] == "02:00" + assert roster["coverage_pct"] == 22.22 + assert payload["summary"]["calendar_total_active_hhmm"] == "11:00" + assert payload["summary"]["workday_total_active_hhmm"] == "02:00" + + +def test_build_management_payload_applies_alias_and_executive_summary(): + original = MODULE.load_manager_aliases + try: + MODULE.load_manager_aliases = lambda: { + "sharkon2025\\user1": { + "display_name": "Иван Петров", + "manager": "Руководитель смены", + "department": "Бухгалтерия", + "role": "Оператор 1С", + } + } + rows = [ + { + "user": "user1", + "user_id": "SHARKON2025\\user1", + "active_seconds": 0, + "active_hhmm": "00:00", + "first_activity": "", + "last_activity": "", + "idle_seconds": 86400, + "sessions_count": 1, + "samples_count": 10, + "active_samples": 0, + "_intervals": [], + } + ] + payload = MODULE.build_management_payload(rows, "SHARKON2025", datetime(2026, 5, 14, tzinfo=timezone.utc).date()) + row = payload["rows"][0] + assert row["user"] == "Иван Петров" + assert row["manager_owner"] == "Руководитель смены" + assert row["department"] == "Бухгалтерия" + assert payload["actions"][0]["owner"] == "Руководитель смены" + assert payload["executive"]["portfolio_state"] == "critical" + assert payload["executive"]["focus_items"][0]["owner"] == "Руководитель смены" + finally: + MODULE.load_manager_aliases = original + + +def test_render_management_html_contains_action_queue(): + payload = { + "generated_at_utc": "2026-05-14T12:00:00Z", + "host": "SHARKON2025", + "report_date": "2026-05-14", + "report_timezone": "Europe/Moscow", + "workday": { + "start_local": "2026-05-14T09:00:00+03:00", + "end_local": "2026-05-14T18:00:00+03:00", + "expected_seconds_per_user": 32400, + "expected_hhmm_per_user": "09:00", + "target_coverage_pct": 75, + "low_coverage_pct": 35, + }, + "summary": { + "users_count": 1, + "active_users": 0, + "inactive_users": 1, + "on_target_users": 0, + "below_target_users": 0, + "portfolio_coverage_pct": 0.0, + "actions_count": 1, + "critical_actions_count": 1, + "high_actions_count": 0, + "total_active_seconds": 0, + "total_active_hhmm": "00:00", + "calendar_total_active_hhmm": "00:00", + "workday_total_active_hhmm": "00:00", + "first_activity": "", + "last_activity": "", + "top_user": "", + "top_user_active_hhmm": "00:00", + }, + "actions": [ + { + "action_id": "missing_activity", + "priority": "critical", + "owner": "Руководитель смены", + "user_id": "SHARKON2025\\user1", + "deadline_hint": "today", + "reason": "Нет активности", + "recommended_action": "Проверить сотрудника", + "evidence": {}, + } + ], + "rows": [ + { + "user": "Иван Петров", + "user_id": "SHARKON2025\\user1", + "canonical_user_id": "SHARKON2025\\user1", + "manager_owner": "Руководитель смены", + "department": "Бухгалтерия", + "active_hhmm": "00:00", + "calendar_active_hhmm": "00:00", + "workday_active_hhmm": "00:00", + "coverage_pct": 0.0, + "status": "inactive", + "first_activity_local": "", + "last_activity_local": "", + "workday_first_activity_local": "", + "workday_last_activity_local": "", + "sessions_count": 1, + } + ], + "executive": { + "portfolio_state": "critical", + "headline": "Есть 1 критичный вопрос, требующий решения сегодня.", + "message": "Активны 0 из 1 сотрудников. Покрытие рабочего окна 0.0%.", + "focus_items": [ + { + "priority": "critical", + "owner": "Руководитель смены", + "title": "missing_activity", + "reason": "Нет активности", + "recommended_action": "Проверить сотрудника", + } + ], + "stale_sources": [], + }, + "trend": [ + { + "report_date": "2026-05-14", + "users_count": 1, + "active_users": 0, + "inactive_users": 1, + "workday_total_active_hhmm": "00:00", + "portfolio_coverage_pct": 0.0, + "actions_count": 1, + "critical_actions_count": 1, + } + ], + "sources": [ + { + "label": "RDP worktime sessions", + "status": "ok", + "status_label": "fresh", + "bucket_id": "aw-worktime-sessions_SHARKON2025", + "timestamp": "2026-05-14T12:00:00Z", + "age_seconds": 30, + "required": True, + "summary": "fresh (30s)", + "event_summary": "queue=0 failures=0 flushed=10", + } + ], + "bucket_id": "aw-worktime-sessions_SHARKON2025", + "report_bounds": { + "start_utc": "2026-05-13T21:00:00Z", + "end_utc": "2026-05-14T20:59:59Z", + }, + } + html = MODULE.render_management_html(payload, selected_day="today") + assert "Управленческий отчёт по работе в RDP" in html + assert "Очередь действий руководителя" in html + assert "missing_activity" in html + assert "Тренд за" in html + assert "Свежесть источников данных" in html + assert "Что делать сегодня" in html + assert "Иван Петров" in html + assert "Руководитель смены" in html + + +def test_build_source_freshness_uses_freshest_candidate_bucket(): + original = MODULE.latest_bucket_event + try: + def fake_latest(bucket_id): + if bucket_id == "aw-file-operations_SHARKON2025": + return { + "timestamp": "2026-05-14T12:00:00Z", + "data": {"signalType": "collector_health", "queueDepth": 9, "sendFailures": 3, "eventsFlushed": 10}, + } + if bucket_id == "aw-file-operations_10.10.10.13": + return { + "timestamp": "2026-05-23T12:00:00Z", + "data": {"signalType": "collector_health", "queueDepth": 0, "sendFailures": 0, "eventsFlushed": 50}, + } + return None + + MODULE.latest_bucket_event = fake_latest + sources, actions = MODULE.build_source_freshness("SHARKON2025") + file_source = next(source for source in sources if source["source_id"] == "file_operations") + assert file_source["bucket_id"] == "aw-file-operations_10.10.10.13" + assert file_source["status"] == "ok" + assert not any(action["evidence"].get("source_id") == "file_operations" for action in actions) + finally: + MODULE.latest_bucket_event = original diff --git a/aw-server/worktime-manager-aliases.example.json b/aw-server/worktime-manager-aliases.example.json new file mode 100644 index 0000000..b86ec3b --- /dev/null +++ b/aw-server/worktime-manager-aliases.example.json @@ -0,0 +1,26 @@ +{ + "users": { + "SHARKON2025\\USER1": { + "display_name": "Иван Петров", + "manager": "Руководитель смены", + "department": "Бухгалтерия", + "role": "Оператор 1С", + "notes": "Дневная смена", + "exclude": false + }, + "SHARKON2025\\USER4": { + "display_name": "Мария Сидорова", + "manager": "Руководитель смены", + "department": "Бухгалтерия", + "role": "Оператор 1С", + "exclude": false + }, + "SHARKON2025\\ADMINISTRATOR": { + "display_name": "Администратор", + "manager": "Ops", + "department": "Инфраструктура", + "role": "Системный администратор", + "exclude": false + } + } +} diff --git a/docs/PRESENTATION_RU.md b/docs/PRESENTATION_RU.md index 621f596..b9dc346 100644 --- a/docs/PRESENTATION_RU.md +++ b/docs/PRESENTATION_RU.md @@ -62,6 +62,18 @@ Live endpoint: - `http://aw-local-server:5610/reports/worktime/today?format=html&date=2026-05-15` +Дополнительно в management-only контуре есть отдельный управленческий отчёт: + +- `http://aw-local-server:5610/reports/worktime/management?format=html&day=today` + +Он показывает не только roster, но и: + +- `Что делать сегодня`; +- покрытие рабочего окна против календарной активности; +- очередь действий руководителя; +- trend; +- свежесть источников данных. + ## 7. AW-rus: DLP review, rules и события Экран AW-rus по bucket `aw-dlp-endpoint-signals_*`: здесь видны живые DLP-события, review-вердикты, правила, case-management и нижняя лента событий. Это прямое доказательство, что данные реально приходят на сервер до агрегации в InfluxDB/Grafana. diff --git a/docs/dlp-security-functional-spec-ru.md b/docs/dlp-security-functional-spec-ru.md index aa56fb0..bba568e 100644 --- a/docs/dlp-security-functional-spec-ru.md +++ b/docs/dlp-security-functional-spec-ru.md @@ -264,7 +264,7 @@ Deployment/tooling: | ActivityWatch API | `5600` | основной API buckets/events/settings | | Policy Engine | `5601` | централизованная политика DLP | | Case Management | `5602` | кейсы расследования | -| Worktime API | `5610` | отчёты worktime CSV/JSON | +| Worktime API | `5610` | отчёты worktime CSV/JSON/HTML и management view `/reports/worktime/management` | ### 6.2 Systemd units @@ -608,4 +608,3 @@ python3 scripts/dlp-admin-cli.py cases list --limit 50 - `docs/windows/validation.md` - `docs/runbook.md` - `docs/worktime_aql_detmir.md` - diff --git a/docs/runbook.md b/docs/runbook.md index 572b76b..7cda22d 100755 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -80,6 +80,42 @@ ls -l /opt/activitywatch/webui-ru/js/ Playbook вычисляет `durationDefault` автоматически (включая смены через полночь) и выставляет: `/api/0/settings/startOfDay` и `/api/0/settings/durationDefault`. +### Management report API + +На `:5610` сейчас есть два server-side отчёта: + +- классический `RDP worktime`: + - `/reports/worktime/today` + - форматы: `json`, `csv`, `html` +- управленческий `management`: + - `/reports/worktime/management` + - форматы: `json`, `csv`, `html` + +Управленческий отчёт дополнительно показывает: + +- рабочее окно отдельно от календарной активности; +- очередь действий руководителя; +- тренд за несколько дней; +- свежесть источников данных; +- executive summary `Что делать сегодня`. + +Практические проверки: + +```sh +curl -fsS 'http://127.0.0.1:5610/reports/worktime/today?day=today' | jq '.[:5]' +curl -fsS 'http://127.0.0.1:5610/reports/worktime/management?day=today' | jq '.summary,.executive' +curl -fsS 'http://127.0.0.1:5610/reports/worktime/management?format=html&day=today' | head +``` + +Важно: + +- первый `management`-запрос после очистки cache может быть тяжёлым, потому что + API пересчитывает trend и source freshness; +- повторные запросы должны быть быстрыми за счёт cache в + `/var/lib/activitywatch/worktime-cache`; +- alias-файл для нормализации сотрудников по умолчанию: + `/etc/activitywatch/worktime-manager-aliases.json`. + ## Типовые инциденты ### Hayabusa: операторский сценарий по умолчанию