fix(aw): harden collectors and grafana exports
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AW_DATA_DIR="${AW_DATA_DIR:-/var/lib/activitywatch}"
|
||||
BACKUP_DIR="${AW_BACKUP_DIR:-${AW_DATA_DIR}/backups}"
|
||||
KEEP_DAYS="${AW_BACKUP_RETENTION_DAYS:-7}"
|
||||
KEEP_LAST_DB="${AW_BACKUP_KEEP_LAST_DB:-2}"
|
||||
KEEP_LAST_JSON="${AW_BACKUP_KEEP_LAST_JSON:-2}"
|
||||
|
||||
prune_group() {
|
||||
local keep_last="$1"
|
||||
local keep_days="$2"
|
||||
shift 2
|
||||
local files=()
|
||||
local idx=0
|
||||
local cutoff
|
||||
cutoff="$(date -d "-${keep_days} days" +%s)"
|
||||
mapfile -t files < <(find "$@" -maxdepth 1 -type f -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk '{ $1=""; sub(/^ /,""); print }')
|
||||
for path in "${files[@]}"; do
|
||||
idx=$((idx + 1))
|
||||
if [ "$idx" -le "$keep_last" ]; then
|
||||
continue
|
||||
fi
|
||||
[ -f "$path" ] || continue
|
||||
if [ "$(stat -c %Y "$path")" -lt "$cutoff" ]; then
|
||||
rm -f -- "$path"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
prune_group "$KEEP_LAST_DB" "$KEEP_DAYS" "${BACKUP_DIR}/db"
|
||||
prune_group "$KEEP_LAST_JSON" "$KEEP_DAYS" "$BACKUP_DIR"
|
||||
|
||||
find /tmp -maxdepth 1 -type f \
|
||||
\( -name 'activitywatch-*.zip' -o -name 'hayabusa-*.zip' -o -name 'aw-hayabusa-profiles.txt' \) \
|
||||
-mtime +0 -delete 2>/dev/null || true
|
||||
|
||||
find /tmp -maxdepth 1 -type f \
|
||||
\( -name 'aw-worktime-ui-bridge.py' -o -name 'views-default.json' -o -name 'apply_webui_ru_patch.out' \) \
|
||||
-mtime +1 -delete 2>/dev/null || true
|
||||
+24
-11
@@ -24,6 +24,7 @@
|
||||
["Tools", "Инструменты"],
|
||||
["Raw Data", "Сырые данные"],
|
||||
["Summary", "Сводка"],
|
||||
["Worktime", "Рабочее время"],
|
||||
["All", "Все"],
|
||||
["None", "Нет"],
|
||||
["Date", "Дата"],
|
||||
@@ -236,6 +237,7 @@
|
||||
['Common words in "Uncategorized" events', 'Частые слова в событиях "Без категории"'],
|
||||
["No words with significant duration. You're good to go!", "Нет слов со значимой длительностью. Здесь всё в порядке."],
|
||||
["Top apps", "Топ приложений"],
|
||||
["Top Applications", "Топ приложений"],
|
||||
["Top titles", "Топ заголовков"],
|
||||
["Top URLs", "Топ URL"],
|
||||
["Top domains", "Топ доменов"],
|
||||
@@ -596,8 +598,8 @@
|
||||
}, "");
|
||||
|
||||
center.innerHTML =
|
||||
'<h4>RDP summary</h4>' +
|
||||
'<p>Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная RDP-сессия.</p>' +
|
||||
'<h4>RDP сводка</h4>' +
|
||||
'<p>Этот блок строится из bucket <code>aw-worktime-sessions</code> через AW API и показывает сводку по RDP-сессиям выбранного хоста.</p>' +
|
||||
'<div class="aw-ru-rdp-grid">' +
|
||||
'<section class="aw-ru-rdp-card"><h5>Активное время</h5><div class="aw-ru-rdp-value">' + escapeHtml(formatDurationSeconds(totalActiveSeconds)) + '</div></section>' +
|
||||
'<section class="aw-ru-rdp-card"><h5>Активных пользователей</h5><div class="aw-ru-rdp-value">' + escapeHtml(String(activeUsers.length)) + '</div></section>' +
|
||||
@@ -844,9 +846,15 @@
|
||||
}
|
||||
|
||||
function removeBadDlpLinks(root) {
|
||||
const badLinks = root.querySelectorAll("a[href*='/view/DLP']");
|
||||
badLinks.forEach(function (link) {
|
||||
const links = Array.from(root.querySelectorAll("a[href], [role='link']"));
|
||||
links.forEach(function (link) {
|
||||
const href = String(link.getAttribute("href") || "");
|
||||
const label = normalizeText(link.textContent || "");
|
||||
const isBrokenActivityDlpLink = /\/view\/dlp(?:[/?#]|$)/i.test(href);
|
||||
const isActivityTabDlpLabel = label === "DLP" && !!link.closest("li");
|
||||
if (!isBrokenActivityDlpLink && !isActivityTabDlpLabel) return;
|
||||
const item = link.closest("li") || link;
|
||||
if (item && item.getAttribute && item.getAttribute("data-aw-ru-dlp-item") === "1") return;
|
||||
item.remove();
|
||||
});
|
||||
}
|
||||
@@ -2113,6 +2121,16 @@
|
||||
});
|
||||
}
|
||||
|
||||
function applyTextAndNavigationPatches(root) {
|
||||
if (!root) return;
|
||||
walk(root);
|
||||
translateAttributes(root);
|
||||
hideNoiseNavigation(root);
|
||||
hidePveAuditTabForRegularHost(root);
|
||||
patchActivityHeading(root);
|
||||
patchCategoryBuilderHostLabel(root);
|
||||
}
|
||||
|
||||
function detachObserver() {
|
||||
if (!observerAttached) return;
|
||||
observer.disconnect();
|
||||
@@ -2147,6 +2165,7 @@
|
||||
if (existing && existing.parentElement) existing.parentElement.removeChild(existing);
|
||||
}
|
||||
}
|
||||
applyTextAndNavigationPatches(document.body);
|
||||
staticPatchRouteKey = routeKey;
|
||||
return;
|
||||
}
|
||||
@@ -2154,13 +2173,8 @@
|
||||
ensureSettingsHost();
|
||||
ensureHostGroupsData().catch(function () {});
|
||||
normalizeCategoryBuilderUnknownHostRefs();
|
||||
applyTextAndNavigationPatches(document.body);
|
||||
if (routeChanged) {
|
||||
walk(document.body);
|
||||
translateAttributes(document.body);
|
||||
hideNoiseNavigation(document.body);
|
||||
hidePveAuditTabForRegularHost(document.body);
|
||||
patchActivityHeading(document.body);
|
||||
patchCategoryBuilderHostLabel(document.body);
|
||||
staticPatchRouteKey = routeKey;
|
||||
}
|
||||
injectPveAuditCenter(document.body);
|
||||
@@ -2187,7 +2201,6 @@
|
||||
|
||||
const observer = new MutationObserver(function () {
|
||||
if (applyPatchInFlight) return;
|
||||
if (isDlpSignalBucketRoute() && document.body && document.body.querySelector("[data-aw-ru-dlp-center='1']")) return;
|
||||
scheduleApplyPatch();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -46,8 +47,14 @@ MANAGER_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_MANAGER_CACHE
|
||||
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()}
|
||||
EVENTS_CACHE_TTL_SECONDS = max(0, int(os.environ.get("AW_WORKTIME_EVENTS_CACHE_TTL_SECONDS", "30")))
|
||||
WORKTIME_EVENTS_LIMIT = max(1000, int(os.environ.get("AW_WORKTIME_EVENTS_LIMIT", "50000")))
|
||||
MODULE_PATH = Path(__file__).resolve()
|
||||
_ALIASES_CACHE = {"mtime": None, "users": {}, "owners": {}, "raw": {}}
|
||||
_EVENTS_CACHE_LOCK = threading.Lock()
|
||||
_EVENTS_CACHE = {}
|
||||
_MANAGEMENT_BUILD_LOCKS_LOCK = threading.Lock()
|
||||
_MANAGEMENT_BUILD_LOCKS = {}
|
||||
|
||||
|
||||
def get(u):
|
||||
@@ -274,6 +281,16 @@ def get_sessions_bucket_id(host):
|
||||
return f"aw-worktime-sessions_{resolve_host(host)}"
|
||||
|
||||
|
||||
def get_management_build_lock(host, report_date):
|
||||
key = (resolve_host(host), report_date.isoformat())
|
||||
with _MANAGEMENT_BUILD_LOCKS_LOCK:
|
||||
lock = _MANAGEMENT_BUILD_LOCKS.get(key)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
_MANAGEMENT_BUILD_LOCKS[key] = lock
|
||||
return lock
|
||||
|
||||
|
||||
def resolve_report_date(day=None, date_text=None):
|
||||
now_local = datetime.now(REPORT_TZ)
|
||||
if date_text:
|
||||
@@ -512,17 +529,31 @@ def aggregate_hourly_rows(events, start, end, host):
|
||||
def fetch_events_for_date(host, report_date):
|
||||
bounds = get_report_bounds(report_date)
|
||||
bucket_id = get_sessions_bucket_id(host)
|
||||
events = fetch_bucket_events(bucket_id, host)
|
||||
return bounds, events
|
||||
|
||||
|
||||
def fetch_bucket_events(bucket_id, host):
|
||||
now = now_utc()
|
||||
if EVENTS_CACHE_TTL_SECONDS > 0:
|
||||
with _EVENTS_CACHE_LOCK:
|
||||
cached = _EVENTS_CACHE.get(bucket_id)
|
||||
if cached is not None and age_seconds(cached["stored_at"], now=now) <= EVENTS_CACHE_TTL_SECONDS:
|
||||
return cached["events"]
|
||||
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, []
|
||||
return []
|
||||
try:
|
||||
events = get(f"{AW}/buckets/{bucket_id}/events?limit=50000")
|
||||
events = get(f"{AW}/buckets/{bucket_id}/events?limit={WORKTIME_EVENTS_LIMIT}")
|
||||
except Exception:
|
||||
log_warning(f"events fetch failed for host={host} bucket={bucket_id} aw_base={AW}")
|
||||
return bounds, []
|
||||
return bounds, events
|
||||
return []
|
||||
if EVENTS_CACHE_TTL_SECONDS > 0:
|
||||
with _EVENTS_CACHE_LOCK:
|
||||
_EVENTS_CACHE[bucket_id] = {"stored_at": now, "events": events}
|
||||
return events
|
||||
|
||||
|
||||
def build_report_summary(rows):
|
||||
@@ -971,9 +1002,10 @@ def load_management_cache(host, report_date):
|
||||
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
|
||||
if report_date >= datetime.now(REPORT_TZ).date():
|
||||
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:
|
||||
@@ -1360,27 +1392,42 @@ def _build_management_core(rows, host, report_date, owner_filter="", department_
|
||||
}
|
||||
|
||||
|
||||
def build_management_trend(host, anchor_date, owner_filter="", department_filter=""):
|
||||
def _management_trend_item(payload, report_date):
|
||||
summary = payload["summary"]
|
||||
return {
|
||||
"report_date": report_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"],
|
||||
}
|
||||
|
||||
|
||||
def build_management_trend(host, anchor_date, owner_filter="", department_filter="", precomputed_payloads=None):
|
||||
trend = []
|
||||
precomputed_payloads = precomputed_payloads or {}
|
||||
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, owner_filter=owner_filter, department_filter=department_filter)
|
||||
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"],
|
||||
}
|
||||
)
|
||||
payload = precomputed_payloads.get(current_date)
|
||||
if payload is None:
|
||||
payload = load_management_cache(host, current_date)
|
||||
if payload is None:
|
||||
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, owner_filter=owner_filter, department_filter=department_filter)
|
||||
elif owner_filter or department_filter:
|
||||
payload = apply_management_filters_to_payload(
|
||||
payload,
|
||||
owner_filter=owner_filter,
|
||||
department_filter=department_filter,
|
||||
include_sources=False,
|
||||
include_source_actions=False,
|
||||
)
|
||||
trend.append(_management_trend_item(payload, current_date))
|
||||
return trend
|
||||
|
||||
|
||||
@@ -1423,7 +1470,13 @@ def build_management_payload(rows, host, report_date, owner_filter="", departmen
|
||||
payload = _build_management_core(rows, host, report_date, owner_filter=owner_filter, department_filter=department_filter)
|
||||
source_freshness, source_actions = build_source_freshness(resolve_host(host))
|
||||
payload["sources"] = source_freshness
|
||||
payload["trend"] = build_management_trend(resolve_host(host), report_date, owner_filter=owner_filter, department_filter=department_filter)
|
||||
payload["trend"] = build_management_trend(
|
||||
resolve_host(host),
|
||||
report_date,
|
||||
owner_filter=owner_filter,
|
||||
department_filter=department_filter,
|
||||
precomputed_payloads={report_date: payload},
|
||||
)
|
||||
payload["trend_scope"] = "portfolio"
|
||||
if source_actions:
|
||||
payload["actions"].extend(filter_management_actions(source_actions, payload["rows"], owner_filter=owner_filter, department_filter=department_filter))
|
||||
@@ -1461,11 +1514,16 @@ def management_report_for_date(host, report_date, owner_filter="", department_fi
|
||||
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
|
||||
lock = get_management_build_lock(host, report_date)
|
||||
with lock:
|
||||
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):
|
||||
|
||||
@@ -8,7 +8,7 @@ WORKTIME_HEALTH_URL="${WORKTIME_HEALTH_URL:-http://127.0.0.1:5610/health}"
|
||||
WORKTIME_REPORT_TIMEOUT_SECONDS="${WORKTIME_REPORT_TIMEOUT_SECONDS:-20}"
|
||||
WORKTIME_MANAGEMENT_WARM_ENABLED="${WORKTIME_MANAGEMENT_WARM_ENABLED:-1}"
|
||||
WORKTIME_MANAGEMENT_WARM_URL="${WORKTIME_MANAGEMENT_WARM_URL:-http://127.0.0.1:5610/reports/worktime/management?day=today&format=json}"
|
||||
WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-25}"
|
||||
WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS="${WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS:-60}"
|
||||
WORKTIME_TODAY_PROBE_ENABLED="${WORKTIME_TODAY_PROBE_ENABLED:-1}"
|
||||
WORKTIME_TODAY_PROBE_URL="${WORKTIME_TODAY_PROBE_URL:-http://127.0.0.1:5610/reports/worktime/today?day=today&format=json}"
|
||||
WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS="${WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS:-20}"
|
||||
@@ -28,7 +28,6 @@ probe_url() {
|
||||
|
||||
probe_reports() {
|
||||
probe_url "$WORKTIME_HEALTH_URL" "$WORKTIME_REPORT_TIMEOUT_SECONDS" || return 1
|
||||
probe_url "$WORKTIME_MANAGEMENT_WARM_URL" "$WORKTIME_MANAGEMENT_WARM_TIMEOUT_SECONDS" || return 1
|
||||
if [[ "$WORKTIME_TODAY_PROBE_ENABLED" == "1" ]]; then
|
||||
probe_url "$WORKTIME_TODAY_PROBE_URL" "$WORKTIME_TODAY_PROBE_TIMEOUT_SECONDS" || return 1
|
||||
fi
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
(function () {
|
||||
var reportBase = "__AW_WORKTIME_REPORT_BASE__";
|
||||
function defaultDayQuery() {
|
||||
var now = new Date();
|
||||
return now.getHours() < 6 ? "day=yesterday" : "day=today";
|
||||
return "day=today";
|
||||
}
|
||||
|
||||
var dayQuery = defaultDayQuery();
|
||||
|
||||
@@ -12,7 +12,7 @@ STATE_PATH = os.environ.get(
|
||||
"AW_WORKTIME_UI_BRIDGE_STATE",
|
||||
"/var/lib/activitywatch/aw-worktime-ui-bridge-state.json",
|
||||
)
|
||||
TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "20"))
|
||||
TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "60"))
|
||||
WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
@@ -29,6 +29,8 @@ WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}"
|
||||
WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{HOST}"
|
||||
WEB_CATEGORY_BUCKET = f"aw-detmir-web-category_{HOST}"
|
||||
COLLECTOR_HEALTH_MAX_AGE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_MAX_AGE_SECONDS", "300"))
|
||||
COLLECTOR_HEALTH_QUERY_LIMIT = int(os.environ.get("AW_WORKTIME_UI_BRIDGE_COLLECTOR_HEALTH_QUERY_LIMIT", "200"))
|
||||
FOREGROUND_CONTEXT_CACHE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_FOREGROUND_CACHE_SECONDS", "900"))
|
||||
|
||||
|
||||
def _req(method: str, path: str, payload=None):
|
||||
@@ -100,6 +102,8 @@ def watcher_window_needs_bridge_sync(now_utc: datetime):
|
||||
source = str(data.get("source", "")).strip().lower()
|
||||
app = str(data.get("app", "")).strip()
|
||||
title = str(data.get("title", "")).strip()
|
||||
if source == "aw-worktime-ui-bridge":
|
||||
return True
|
||||
if source != "aw-worktime-ui-bridge":
|
||||
return False
|
||||
if app.upper() == "RDP":
|
||||
@@ -148,22 +152,54 @@ def build_window_title(users, active_count):
|
||||
return f"RDP active ({active_count}): " + ", ".join(users)
|
||||
|
||||
|
||||
def get_latest_foreground_context(now_utc: datetime):
|
||||
def get_latest_active_session_ids(events):
|
||||
grouped = {}
|
||||
for event in events:
|
||||
ts = event.get("timestamp")
|
||||
if not ts:
|
||||
continue
|
||||
grouped.setdefault(ts, []).append(event)
|
||||
if not grouped:
|
||||
return set()
|
||||
latest_ts = max(grouped.keys(), key=lambda item: parse_iso_utc(item))
|
||||
active_session_ids = set()
|
||||
for event in grouped.get(latest_ts, []):
|
||||
data = event.get("data") or {}
|
||||
if not _is_session_active(data):
|
||||
continue
|
||||
try:
|
||||
active_session_ids.add(int(data.get("sessionId")))
|
||||
except Exception:
|
||||
continue
|
||||
return active_session_ids
|
||||
|
||||
|
||||
def _normalize_foreground_context(data):
|
||||
foreground_process = str(data.get("foregroundProcess", "")).strip()
|
||||
foreground_title = str(data.get("foregroundTitle", "")).strip()
|
||||
if not foreground_process and not foreground_title:
|
||||
return None
|
||||
return {
|
||||
"app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe",
|
||||
"title": foreground_title or foreground_process,
|
||||
}
|
||||
|
||||
|
||||
def get_latest_foreground_context(now_utc: datetime, active_session_ids=None, state=None):
|
||||
try:
|
||||
events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit=20") or []
|
||||
events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit={COLLECTOR_HEALTH_QUERY_LIMIT}") or []
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 404:
|
||||
return None
|
||||
raise
|
||||
events = []
|
||||
else:
|
||||
raise
|
||||
|
||||
for event in events:
|
||||
active_session_ids = set(active_session_ids or [])
|
||||
recent_candidates = []
|
||||
for event in reversed(events):
|
||||
data = event.get("data") or {}
|
||||
if str(data.get("signalType", "")).strip().lower() != "collector_health":
|
||||
continue
|
||||
foreground_process = str(data.get("foregroundProcess", "")).strip()
|
||||
foreground_title = str(data.get("foregroundTitle", "")).strip()
|
||||
if not foreground_process and not foreground_title:
|
||||
continue
|
||||
ts = event.get("timestamp")
|
||||
if not ts:
|
||||
continue
|
||||
@@ -173,11 +209,43 @@ def get_latest_foreground_context(now_utc: datetime):
|
||||
continue
|
||||
if (now_utc - event_dt).total_seconds() > COLLECTOR_HEALTH_MAX_AGE_SECONDS:
|
||||
continue
|
||||
return {
|
||||
"app": foreground_process if foreground_process.endswith(".exe") else f"{foreground_process}.exe",
|
||||
"title": foreground_title or foreground_process,
|
||||
}
|
||||
normalized = _normalize_foreground_context(data)
|
||||
if not normalized:
|
||||
continue
|
||||
session_id = data.get("sessionId")
|
||||
try:
|
||||
session_id = int(session_id)
|
||||
except Exception:
|
||||
session_id = None
|
||||
recent_candidates.append((session_id, event_dt, normalized))
|
||||
|
||||
for session_id, event_dt, normalized in recent_candidates:
|
||||
if active_session_ids and session_id in active_session_ids:
|
||||
normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
|
||||
return normalized
|
||||
|
||||
if recent_candidates:
|
||||
session_id, event_dt, normalized = recent_candidates[0]
|
||||
normalized["timestamp"] = to_iso_utc(event_dt.isoformat())
|
||||
return normalized
|
||||
|
||||
cached = (state or {}).get("last_foreground_context")
|
||||
if isinstance(cached, dict):
|
||||
cached_ts = str(cached.get("timestamp", "")).strip()
|
||||
if cached_ts:
|
||||
try:
|
||||
cached_dt = parse_iso_utc(cached_ts)
|
||||
except Exception:
|
||||
cached_dt = None
|
||||
if cached_dt and (now_utc - cached_dt).total_seconds() <= FOREGROUND_CONTEXT_CACHE_SECONDS:
|
||||
app = str(cached.get("app", "")).strip()
|
||||
title = str(cached.get("title", "")).strip()
|
||||
if app or title:
|
||||
return {
|
||||
"app": app or "RDP",
|
||||
"title": title or app or "RDP",
|
||||
"timestamp": cached_ts,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@@ -229,13 +297,19 @@ def transform(events, foreground_context=None):
|
||||
rows = grouped[ts]
|
||||
src_duration = max(float(r.get("duration", 0.0)) for r in rows)
|
||||
duration = src_duration
|
||||
cur_dt = parsed_ts.get(ts)
|
||||
next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None
|
||||
next_gap = None
|
||||
if cur_dt and next_dt:
|
||||
next_gap = max(0.0, (next_dt - cur_dt).total_seconds())
|
||||
if duration <= 0:
|
||||
cur_dt = parsed_ts.get(ts)
|
||||
next_dt = parsed_ts.get(ordered_ts[idx + 1]) if idx + 1 < len(ordered_ts) else None
|
||||
if cur_dt and next_dt:
|
||||
duration = max(0.0, (next_dt - cur_dt).total_seconds())
|
||||
duration = next_gap or 0.0
|
||||
if duration <= 0:
|
||||
duration = 10.0
|
||||
elif next_gap is not None and next_gap > 0:
|
||||
# Do not let a sampled session interval extend past the next sample.
|
||||
duration = min(duration, next_gap)
|
||||
duration = min(duration, 30.0)
|
||||
active_users = []
|
||||
for r in rows:
|
||||
@@ -271,6 +345,22 @@ def transform(events, foreground_context=None):
|
||||
return out_afk, out_win, last_ts
|
||||
|
||||
|
||||
def normalize_watcher_window_events(win_events):
|
||||
normalized = []
|
||||
for event in win_events:
|
||||
cloned = dict(event)
|
||||
data = dict(event.get("data") or {})
|
||||
app = str(data.get("app") or "").strip()
|
||||
title = str(data.get("title") or "").strip()
|
||||
if not app or app.upper() == "RDP":
|
||||
continue
|
||||
if app and app.upper() != "RDP" and " | RDP active (" in title:
|
||||
data["title"] = title.split(" | RDP active (", 1)[0].strip()
|
||||
cloned["data"] = data
|
||||
normalized.append(cloned)
|
||||
return normalized
|
||||
|
||||
|
||||
def main():
|
||||
state = load_state()
|
||||
last_ts = state.get("last_ts", "1970-01-01T00:00:00Z")
|
||||
@@ -301,10 +391,12 @@ def main():
|
||||
if not events:
|
||||
return
|
||||
|
||||
foreground_context = get_latest_foreground_context(now_utc)
|
||||
active_session_ids = get_latest_active_session_ids(events)
|
||||
foreground_context = get_latest_foreground_context(now_utc, active_session_ids=active_session_ids, state=state)
|
||||
afk_events, win_events, new_last_ts = transform(events, foreground_context=foreground_context)
|
||||
if not afk_events or not win_events or not new_last_ts:
|
||||
return
|
||||
watcher_win_events = normalize_watcher_window_events(win_events)
|
||||
|
||||
_req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events)
|
||||
_req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events)
|
||||
@@ -312,10 +404,19 @@ def main():
|
||||
if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS):
|
||||
ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk")
|
||||
_req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events)
|
||||
if watcher_window_needs_bridge_sync(now_utc):
|
||||
if watcher_win_events and watcher_window_needs_bridge_sync(now_utc):
|
||||
ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window")
|
||||
_req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", win_events)
|
||||
save_state({"last_ts": new_last_ts})
|
||||
_req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", watcher_win_events)
|
||||
next_state = {"last_ts": new_last_ts}
|
||||
if foreground_context:
|
||||
next_state["last_foreground_context"] = {
|
||||
"app": str(foreground_context.get("app") or ""),
|
||||
"title": str(foreground_context.get("title") or ""),
|
||||
"timestamp": str(foreground_context.get("timestamp") or to_iso_utc(now_utc.isoformat())),
|
||||
}
|
||||
elif isinstance(state.get("last_foreground_context"), dict):
|
||||
next_state["last_foreground_context"] = state["last_foreground_context"]
|
||||
save_state(next_state)
|
||||
print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}")
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Description=AW Worktime UI bridge (sessions -> afk/window)
|
||||
After=network-online.target activitywatch-server.service
|
||||
Wants=network-online.target
|
||||
StartLimitBurst=3
|
||||
StartLimitBurst=20
|
||||
StartLimitIntervalSec=120
|
||||
|
||||
[Service]
|
||||
|
||||
@@ -16,11 +16,6 @@
|
||||
{ "type": "top_apps", "size": 3, "props": {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "DLP",
|
||||
"name": "DLP",
|
||||
"elements": []
|
||||
},
|
||||
{
|
||||
"id": "worktime",
|
||||
"name": "Worktime",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import importlib.util
|
||||
import json
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -225,7 +227,7 @@ def test_build_management_payload_applies_alias_and_executive_summary():
|
||||
}
|
||||
}
|
||||
MODULE.build_source_freshness = lambda host: ([], [])
|
||||
MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="": []
|
||||
MODULE.build_management_trend = lambda host, report_date, owner_filter="", department_filter="", **kwargs: []
|
||||
rows = [
|
||||
{
|
||||
"user": "user1",
|
||||
@@ -342,6 +344,81 @@ def test_build_management_payload_filters_by_owner():
|
||||
MODULE.build_source_freshness = original_sources
|
||||
|
||||
|
||||
def _minimal_management_payload(report_date, users_count=1):
|
||||
return {
|
||||
"summary": {
|
||||
"users_count": users_count,
|
||||
"active_users": users_count,
|
||||
"inactive_users": 0,
|
||||
"workday_total_active_seconds": 3600 * users_count,
|
||||
"workday_total_active_hhmm": f"0{users_count}:00",
|
||||
"portfolio_coverage_pct": 100.0,
|
||||
"actions_count": 0,
|
||||
"critical_actions_count": 0,
|
||||
},
|
||||
"rows": [],
|
||||
"actions": [],
|
||||
"sources": [],
|
||||
"filters": {"owner": "", "department": ""},
|
||||
}
|
||||
|
||||
|
||||
def test_load_management_cache_keeps_historical_reports_after_ttl():
|
||||
original_dir = MODULE.MANAGER_CACHE_DIR
|
||||
original_ttl = MODULE.MANAGER_CACHE_TTL_SECONDS
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
MODULE.MANAGER_CACHE_DIR = Path(tmp)
|
||||
MODULE.MANAGER_CACHE_TTL_SECONDS = 1
|
||||
report_date = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
|
||||
payload = _minimal_management_payload(report_date)
|
||||
path = MODULE.management_cache_path("SHARKON2025", report_date)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
loaded = MODULE.load_management_cache("SHARKON2025", report_date)
|
||||
assert loaded["summary"]["users_count"] == 1
|
||||
finally:
|
||||
MODULE.MANAGER_CACHE_DIR = original_dir
|
||||
MODULE.MANAGER_CACHE_TTL_SECONDS = original_ttl
|
||||
|
||||
|
||||
def test_build_management_trend_reuses_precomputed_anchor_payload():
|
||||
original_trend_days = MODULE.MANAGER_TREND_DAYS
|
||||
original_load = MODULE.load_management_cache
|
||||
original_fetch = MODULE.fetch_events_for_date
|
||||
original_aggregate = MODULE.aggregate_rows_with_intervals
|
||||
fetch_dates = []
|
||||
try:
|
||||
MODULE.MANAGER_TREND_DAYS = 3
|
||||
anchor = datetime(2026, 5, 14, tzinfo=timezone.utc).date()
|
||||
|
||||
def fake_load(host, report_date):
|
||||
if report_date < anchor:
|
||||
return _minimal_management_payload(report_date)
|
||||
return None
|
||||
|
||||
def fake_fetch(host, report_date):
|
||||
fetch_dates.append(report_date)
|
||||
return MODULE.get_report_bounds(report_date), []
|
||||
|
||||
MODULE.load_management_cache = fake_load
|
||||
MODULE.fetch_events_for_date = fake_fetch
|
||||
MODULE.aggregate_rows_with_intervals = lambda events, start, end, host: []
|
||||
trend = MODULE.build_management_trend(
|
||||
"SHARKON2025",
|
||||
anchor,
|
||||
precomputed_payloads={anchor: _minimal_management_payload(anchor, users_count=2)},
|
||||
)
|
||||
assert [item["report_date"] for item in trend] == ["2026-05-12", "2026-05-13", "2026-05-14"]
|
||||
assert trend[-1]["users_count"] == 2
|
||||
assert fetch_dates == []
|
||||
finally:
|
||||
MODULE.MANAGER_TREND_DAYS = original_trend_days
|
||||
MODULE.load_management_cache = original_load
|
||||
MODULE.fetch_events_for_date = original_fetch
|
||||
MODULE.aggregate_rows_with_intervals = original_aggregate
|
||||
|
||||
|
||||
def test_render_management_html_contains_action_queue():
|
||||
payload = {
|
||||
"generated_at_utc": "2026-05-14T12:00:00Z",
|
||||
|
||||
@@ -47,6 +47,79 @@ class WorktimeUiBridgeTests(unittest.TestCase):
|
||||
self.assertEqual(ctx["app"], "totalcmd.exe")
|
||||
self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
|
||||
|
||||
def test_get_latest_active_session_ids_uses_latest_timestamp_group(self):
|
||||
events = [
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:25Z",
|
||||
"data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:30Z",
|
||||
"data": {"sessionId": 2, "state": "Активно", "username": "администратор", "sessionName": "console"},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:30Z",
|
||||
"data": {"sessionId": 3, "state": "Активно", "username": "user5", "sessionName": "rdp-tcp#0"},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:30Z",
|
||||
"data": {"sessionId": 4, "state": "Диск", "username": "user1", "sessionName": ""},
|
||||
},
|
||||
]
|
||||
self.assertEqual(MODULE.get_latest_active_session_ids(events), {2, 3})
|
||||
|
||||
def test_get_latest_foreground_context_prefers_active_session(self):
|
||||
now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc)
|
||||
recent_events = [
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:40Z",
|
||||
"data": {
|
||||
"signalType": "collector_health",
|
||||
"foregroundProcess": "explorer",
|
||||
"foregroundTitle": "Explorer",
|
||||
"sessionId": 9,
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:45Z",
|
||||
"data": {
|
||||
"signalType": "collector_health",
|
||||
"foregroundProcess": "totalcmd",
|
||||
"foregroundTitle": "Total Commander 6.01 - HARVEST",
|
||||
"sessionId": 2,
|
||||
},
|
||||
},
|
||||
]
|
||||
with mock.patch.object(MODULE, "_req", return_value=recent_events):
|
||||
ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2})
|
||||
self.assertEqual(ctx["app"], "totalcmd.exe")
|
||||
self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
|
||||
|
||||
def test_get_latest_foreground_context_uses_cached_context_when_recent_events_are_blank(self):
|
||||
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
|
||||
recent_events = [
|
||||
{
|
||||
"timestamp": "2026-05-27T08:04:50Z",
|
||||
"data": {
|
||||
"signalType": "collector_health",
|
||||
"foregroundProcess": "",
|
||||
"foregroundTitle": "",
|
||||
"sessionId": 3,
|
||||
},
|
||||
}
|
||||
]
|
||||
state = {
|
||||
"last_foreground_context": {
|
||||
"app": "totalcmd.exe",
|
||||
"title": "Total Commander 6.01 - HARVEST",
|
||||
"timestamp": "2026-05-27T08:03:30Z",
|
||||
}
|
||||
}
|
||||
with mock.patch.object(MODULE, "_req", return_value=recent_events):
|
||||
ctx = MODULE.get_latest_foreground_context(now, active_session_ids={2, 3}, state=state)
|
||||
self.assertEqual(ctx["app"], "totalcmd.exe")
|
||||
self.assertEqual(ctx["title"], "Total Commander 6.01 - HARVEST")
|
||||
|
||||
def test_transform_uses_foreground_context_for_active_sessions(self):
|
||||
events = [
|
||||
{
|
||||
@@ -69,6 +142,61 @@ class WorktimeUiBridgeTests(unittest.TestCase):
|
||||
self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
|
||||
self.assertEqual(last_ts, "2026-05-27T07:59:30Z")
|
||||
|
||||
def test_transform_caps_duration_at_next_timestamp_gap(self):
|
||||
events = [
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:30Z",
|
||||
"duration": 5,
|
||||
"data": {
|
||||
"username": "user5",
|
||||
"state": "Активно",
|
||||
"sessionId": 3,
|
||||
"sessionName": "rdp-tcp#0",
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-27T07:59:31Z",
|
||||
"duration": 5,
|
||||
"data": {
|
||||
"username": "user5",
|
||||
"state": "Активно",
|
||||
"sessionId": 3,
|
||||
"sessionName": "rdp-tcp#0",
|
||||
},
|
||||
},
|
||||
]
|
||||
_, win_events, _ = MODULE.transform(
|
||||
events,
|
||||
foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"},
|
||||
)
|
||||
self.assertEqual(win_events[0]["duration"], 1.0)
|
||||
self.assertEqual(win_events[1]["duration"], 5)
|
||||
|
||||
def test_normalize_watcher_window_events_strips_rdp_suffix_for_real_apps(self):
|
||||
events = [
|
||||
{
|
||||
"timestamp": "2026-05-27T08:10:00Z",
|
||||
"duration": 5,
|
||||
"data": {
|
||||
"app": "totalcmd.exe",
|
||||
"title": "Total Commander 6.01 - HARVEST | RDP active (2): user5, администратор",
|
||||
"source": "aw-worktime-ui-bridge",
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-05-27T08:10:05Z",
|
||||
"duration": 5,
|
||||
"data": {
|
||||
"app": "RDP",
|
||||
"title": "RDP active (2): user5, администратор",
|
||||
"source": "aw-worktime-ui-bridge",
|
||||
},
|
||||
},
|
||||
]
|
||||
normalized = MODULE.normalize_watcher_window_events(events)
|
||||
self.assertEqual(normalized[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
|
||||
self.assertEqual(len(normalized), 1)
|
||||
|
||||
def test_watcher_window_needs_bridge_sync_for_generic_bridge_rdp(self):
|
||||
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
|
||||
latest_event = {
|
||||
@@ -83,6 +211,20 @@ class WorktimeUiBridgeTests(unittest.TestCase):
|
||||
mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
|
||||
self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
|
||||
|
||||
def test_watcher_window_needs_bridge_sync_for_non_generic_bridge_event(self):
|
||||
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
|
||||
latest_event = {
|
||||
"timestamp": "2026-05-27T08:04:40Z",
|
||||
"data": {
|
||||
"app": "totalcmd.exe",
|
||||
"title": "Total Commander 6.01 - HARVEST",
|
||||
"source": "aw-worktime-ui-bridge",
|
||||
},
|
||||
}
|
||||
with mock.patch.object(MODULE, "get_latest_bucket_event", return_value=latest_event), \
|
||||
mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=datetime(2026, 5, 27, 8, 4, 40, tzinfo=timezone.utc)):
|
||||
self.assertTrue(MODULE.watcher_window_needs_bridge_sync(now))
|
||||
|
||||
def test_watcher_window_does_not_override_real_watcher_stream(self):
|
||||
now = datetime(2026, 5, 27, 8, 5, 0, tzinfo=timezone.utc)
|
||||
latest_event = {
|
||||
|
||||
Reference in New Issue
Block a user