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