diff --git a/aw-server/aw-worktime-api.py b/aw-server/aw-worktime-api.py index c1c1b63..a966ecf 100644 --- a/aw-server/aw-worktime-api.py +++ b/aw-server/aw-worktime-api.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import csv +import html import io import importlib.util import json @@ -9,7 +10,7 @@ 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 urllib.parse import parse_qs, urlencode, urlparse from zoneinfo import ZoneInfo @@ -54,6 +55,20 @@ def hhmm(total_seconds): return "%02d:%02d" % (total_seconds // 3600, (total_seconds % 3600) // 60) +def safe_slug(value): + text = str(value or "").strip().lower() + slug = [] + for char in text: + if char.isalnum(): + slug.append(char) + else: + slug.append("-") + normalized = "".join(slug).strip("-") + while "--" in normalized: + normalized = normalized.replace("--", "-") + return normalized or "user" + + def clamp_seconds(value, fallback=DEFAULT_SAMPLE_SECONDS): try: seconds = float(value) @@ -227,6 +242,33 @@ def aggregate_rows(events, start, end, host): return rows +def build_report_summary(rows): + if not rows: + return { + "users_count": 0, + "total_active_seconds": 0, + "total_active_hhmm": "00:00", + "first_activity": "", + "last_activity": "", + "top_user": "", + "top_user_active_hhmm": "00:00", + } + + total_active_seconds = sum(int(row.get("active_seconds", 0) or 0) for row in rows) + first_values = [row.get("first_activity") for row in rows if row.get("first_activity")] + last_values = [row.get("last_activity") for row in rows if row.get("last_activity")] + top_row = max(rows, key=lambda row: int(row.get("active_seconds", 0) or 0)) + return { + "users_count": len(rows), + "total_active_seconds": total_active_seconds, + "total_active_hhmm": hhmm(total_active_seconds), + "first_activity": min(first_values) if first_values else "", + "last_activity": max(last_values) if last_values else "", + "top_user": top_row.get("user", ""), + "top_user_active_hhmm": top_row.get("active_hhmm", "00:00"), + } + + def report_for_date(host, report_date): 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) @@ -262,23 +304,70 @@ def render_html(rows, host, report_date, selected_day=None): date_local = report_date.strftime("%Y-%m-%d") day_query = f"&day={selected_day}" if selected_day in {"today", "yesterday"} else "" date_query = f"&date={date_local}" if not day_query else "" + summary = build_report_summary(rows) + today_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "today"}) + yesterday_url = "/reports/worktime/today?" + urlencode({"format": "html", "host": resolve_host(host), "day": "yesterday"}) + csv_url = "/reports/worktime/today?" + urlencode({"format": "csv", "host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) + json_url = "/reports/worktime/today?" + urlencode({"host": resolve_host(host), **({"day": selected_day} if selected_day in {"today", "yesterday"} else {"date": date_local})}) + form_action = "/reports/worktime/today" + cards = [ + ("Users", str(summary["users_count"])), + ("Total active", summary["total_active_hhmm"]), + ("Top user", f"{summary['top_user']} · {summary['top_user_active_hhmm']}" if summary["top_user"] else "n/a"), + ("Range", f"{summary['first_activity']} -> {summary['last_activity']}" if summary["first_activity"] else "no activity"), + ] trs = [] + detail_cards = [] for row in rows: + user_slug = safe_slug(row["user"]) + active_seconds = int(row.get("active_seconds", 0) or 0) + utilization = 0.0 + day_total = 24 * 3600 + if day_total > 0: + utilization = round((active_seconds / day_total) * 100.0, 2) trs.append( "" - f"{row['user']}" - f"{row['user_id']}" + f"{html.escape(row['user'])}" + f"{html.escape(row['user_id'])}" f"{row['active_hhmm']}" f"{row['active_seconds']}" - f"{row['first_activity']}" - f"{row['last_activity']}" + f"{html.escape(row['first_activity'])}" + f"{html.escape(row['last_activity'])}" f"{row['idle_seconds']}" f"{row['sessions_count']}" f"{row['samples_count']}" "" ) + detail_cards.append( + "
" + "
" + "

{user}

" + "{active}" + "
" + "
" + "
User ID{user_id}
" + "
Utilization{utilization}%
" + "
First activity{first_activity}
" + "
Last activity{last_activity}
" + "
Sessions{sessions}
" + "
Active samples{active_samples} / {samples}
" + "
" + "
" + .format( + slug=user_slug, + user=html.escape(row["user"]), + active=html.escape(row["active_hhmm"]), + user_id=html.escape(row["user_id"]), + utilization=utilization, + first_activity=html.escape(row["first_activity"] or "n/a"), + last_activity=html.escape(row["last_activity"] or "n/a"), + sessions=row["sessions_count"], + active_samples=row["active_samples"], + samples=row["samples_count"], + )) if not trs: trs.append('No data for today yet.') + detail_cards.append("

No per-user activity for selected date.

") return f""" @@ -324,6 +413,59 @@ def render_html(rows, host, report_date, selected_day=None): padding: 8px 12px; border-radius: 999px; }} + .toolbar {{ + margin-top: 16px; + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; + }} + .toolbar form {{ + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; + }} + .toolbar input, .toolbar button {{ + border-radius: 10px; + border: 1px solid rgba(255,255,255,.22); + background: rgba(255,255,255,.14); + color: #fff; + padding: 9px 12px; + font: inherit; + }} + .toolbar button {{ + cursor: pointer; + font-weight: 600; + }} + .toolbar input::-webkit-calendar-picker-indicator {{ filter: invert(1); }} + .summary-grid {{ + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-top: 18px; + }} + .summary-card {{ + background: rgba(255,255,255,.1); + border: 1px solid rgba(255,255,255,.14); + border-radius: 14px; + padding: 14px 16px; + min-height: 96px; + }} + .summary-card span {{ + display: block; + color: rgba(255,255,255,.78); + font-size: 12px; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: .04em; + }} + .summary-card strong {{ + display: block; + font-size: 22px; + line-height: 1.25; + word-break: break-word; + }} .card {{ margin-top: 18px; background: var(--card); @@ -337,11 +479,74 @@ def render_html(rows, host, report_date, selected_day=None): th {{ background: #eef4fb; color: var(--muted); font-weight: 600; position: sticky; top: 0; }} tr:nth-child(even) td {{ background: rgba(148,163,184,.06); }} .good {{ color: var(--accent); font-weight: 700; }} + .user-link {{ color: #0f4db3; text-decoration: none; font-weight: 600; }} + .section-title {{ + margin: 0; + padding: 18px 18px 0; + color: var(--text); + font-size: 18px; + }} + .details-wrap {{ + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + padding: 18px; + }} + .detail-card {{ + border: 1px solid var(--line); + border-radius: 14px; + padding: 16px; + background: linear-gradient(180deg, rgba(238,244,251,.7), #fff); + scroll-margin-top: 16px; + }} + .detail-card.empty {{ + grid-column: 1 / -1; + text-align: center; + color: var(--muted); + }} + .detail-head {{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + }} + .detail-head h3 {{ + margin: 0; + font-size: 18px; + }} + .badge {{ + display: inline-block; + padding: 6px 10px; + background: #d1fae5; + color: #065f46; + border-radius: 999px; + font-weight: 700; + font-size: 12px; + }} + .detail-grid {{ + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + }} + .detail-grid span {{ + display: block; + color: var(--muted); + font-size: 12px; + margin-bottom: 4px; + }} + .detail-grid strong {{ + display: block; + word-break: break-word; + }} @media (max-width: 900px) {{ .wrap {{ padding: 14px; }} .hero h1 {{ font-size: 22px; }} + .summary-grid {{ grid-template-columns: 1fr; }} .card {{ overflow-x: auto; }} table {{ min-width: 1080px; }} + .details-wrap {{ grid-template-columns: 1fr; }} + .detail-grid {{ grid-template-columns: 1fr; }} }} @@ -351,13 +556,25 @@ def render_html(rows, host, report_date, selected_day=None):

RDP Worktime Report

Host: {resolve_host(host)} · Date: {date_local} · Timezone: {REPORT_TZ} · Generated UTC: {generated}
- Today - Yesterday - Download CSV - View JSON + Today + Yesterday + Download CSV + View JSON +
+
+
+ + + + +
+
+
+ {''.join(f"
{html.escape(label)}{html.escape(value)}
" for label, value in cards)}
+

Per-user table

@@ -377,6 +594,12 @@ def render_html(rows, host, report_date, selected_day=None):
+
+

Per-user details

+
+ {''.join(detail_cards)} +
+
"""