fix(activity): restore app-level window stream on rdp host

This commit is contained in:
igor04091968
2026-05-27 11:36:58 +03:00
parent 51cb43666a
commit 524ab33f63
4 changed files with 190 additions and 11 deletions
+86 -10
View File
@@ -27,6 +27,8 @@ AFK_BUCKET = f"aw-rdp-afk_{HOST}"
WINDOW_BUCKET = f"aw-rdp-window_{HOST}"
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"))
def _req(method: str, path: str, payload=None):
@@ -52,7 +54,7 @@ def ensure_bucket(bucket_id: str, event_type: str, client: str):
raise
def get_latest_bucket_event_ts(bucket_id: str):
def get_latest_bucket_event(bucket_id: str):
try:
events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or []
except urllib.error.HTTPError as e:
@@ -61,7 +63,14 @@ def get_latest_bucket_event_ts(bucket_id: str):
raise
if not events:
return None
ts = events[0].get("timestamp")
return events[0]
def get_latest_bucket_event_ts(bucket_id: str):
event = get_latest_bucket_event(bucket_id)
if not event:
return None
ts = event.get("timestamp")
if not ts:
return None
try:
@@ -77,6 +86,29 @@ def bucket_needs_fallback(bucket_id: str, now_utc: datetime, stale_after_seconds
return (now_utc - latest_dt).total_seconds() >= stale_after_seconds
def watcher_window_needs_bridge_sync(now_utc: datetime):
latest_event = get_latest_bucket_event(WATCHER_WINDOW_BUCKET)
if latest_event is None:
return True
latest_dt = get_latest_bucket_event_ts(WATCHER_WINDOW_BUCKET)
if latest_dt is None:
return True
if (now_utc - latest_dt).total_seconds() >= WATCHER_FALLBACK_STALE_SECONDS:
return True
data = latest_event.get("data") or {}
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 False
if app.upper() == "RDP":
return True
if title == "RDP idle" or title.startswith("RDP active"):
return True
return False
def load_state():
try:
with open(STATE_PATH, "r", encoding="utf-8") as f:
@@ -116,6 +148,39 @@ def build_window_title(users, active_count):
return f"RDP active ({active_count}): " + ", ".join(users)
def get_latest_foreground_context(now_utc: datetime):
try:
events = _req("GET", f"/api/0/buckets/{WEB_CATEGORY_BUCKET}/events?limit=20") or []
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
for event in 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
try:
event_dt = parse_iso_utc(ts)
except Exception:
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,
}
return None
def _is_session_active(row_data):
if isinstance(row_data.get("active"), bool):
if row_data.get("active"):
@@ -136,7 +201,7 @@ def _is_session_active(row_data):
return False
def transform(events):
def transform(events, foreground_context=None):
out_afk = []
out_win = []
last_ts = None
@@ -185,11 +250,21 @@ def transform(events):
afk_data = {"status": "not-afk" if is_active else "afk", "source": "aw-worktime-ui-bridge"}
out_afk.append({"timestamp": ts, "duration": duration, "data": afk_data})
win_data = {
"app": "RDP",
"title": build_window_title(active_users, active_count),
"source": "aw-worktime-ui-bridge",
}
if is_active and foreground_context:
title = str(foreground_context.get("title") or "").strip()
if active_count > 1:
title = f"{title} | {build_window_title(active_users, active_count)}" if title else build_window_title(active_users, active_count)
win_data = {
"app": str(foreground_context.get("app") or "RDP"),
"title": title or build_window_title(active_users, active_count),
"source": "aw-worktime-ui-bridge",
}
else:
win_data = {
"app": "RDP",
"title": build_window_title(active_users, active_count),
"source": "aw-worktime-ui-bridge",
}
out_win.append({"timestamp": ts, "duration": duration, "data": win_data})
last_ts = ts
@@ -226,7 +301,8 @@ def main():
if not events:
return
afk_events, win_events, new_last_ts = transform(events)
foreground_context = get_latest_foreground_context(now_utc)
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
@@ -236,7 +312,7 @@ 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 bucket_needs_fallback(WATCHER_WINDOW_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS):
if 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})
+67
View File
@@ -30,6 +30,73 @@ class WorktimeUiBridgeTests(unittest.TestCase):
with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=stale):
self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-afk_SHARKON2025", now, 600))
def test_get_latest_foreground_context_uses_recent_collector_health(self):
now = datetime(2026, 5, 27, 8, 0, 0, tzinfo=timezone.utc)
recent_events = [
{
"timestamp": "2026-05-27T07:59:30Z",
"data": {
"signalType": "collector_health",
"foregroundProcess": "totalcmd",
"foregroundTitle": "Total Commander 6.01 - HARVEST",
},
}
]
with mock.patch.object(MODULE, "_req", return_value=recent_events):
ctx = MODULE.get_latest_foreground_context(now)
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 = [
{
"timestamp": "2026-05-27T07:59:30Z",
"duration": 0,
"data": {
"username": "user5",
"state": "Активно",
"sessionId": 3,
"sessionName": "rdp-tcp#0",
},
}
]
afk_events, win_events, last_ts = MODULE.transform(
events,
foreground_context={"app": "totalcmd.exe", "title": "Total Commander 6.01 - HARVEST"},
)
self.assertEqual(afk_events[0]["data"]["status"], "not-afk")
self.assertEqual(win_events[0]["data"]["app"], "totalcmd.exe")
self.assertEqual(win_events[0]["data"]["title"], "Total Commander 6.01 - HARVEST")
self.assertEqual(last_ts, "2026-05-27T07:59:30Z")
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 = {
"timestamp": "2026-05-27T08:04:40Z",
"data": {
"app": "RDP",
"title": "RDP active (2): user5, администратор",
"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 = {
"timestamp": "2026-05-27T08:04:40Z",
"data": {
"app": "notepad.exe",
"title": "Безымянный — Блокнот",
"source": "aw-watcher-window",
},
}
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.assertFalse(MODULE.watcher_window_needs_bridge_sync(now))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -526,7 +526,7 @@ function Get-ActivityWatchSessionRecords {
$sessionId = [int]$columns[$sessionIdIndex]
$state = if ($columns.Count -gt ($sessionIdIndex + 1)) { [string]$columns[$sessionIdIndex + 1] } else { '' }
$isLive = $state -match '^(Active|Conn)$'
$isLive = $state -match '^(Active|Conn|Активно|Подкл\w*)$'
$sessions.Add([pscustomobject]@{
SessionName = $sessionName
@@ -780,6 +780,39 @@ function Send-Heartbeat {
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event
}
function Send-WindowHeartbeat {
param(
[Parameter(Mandatory = $true)]
[pscustomobject]$Context
)
if (-not $Context) {
return
}
$processName = [string]$Context.ProcessName
$title = [string]$Context.Title
if ([string]::IsNullOrWhiteSpace($processName) -and [string]::IsNullOrWhiteSpace($title)) {
return
}
$bucketId = 'aw-watcher-window_' + $script:Hostname
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-watcher-window' -BucketType 'currentwindow'
$event = @{
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
duration = 0
data = @{
app = if ([string]::IsNullOrWhiteSpace($processName)) { 'unknown.exe' } else { "$processName.exe" }
title = $title
source = 'uia-native'
sessionId = $script:SessionId
}
} | ConvertTo-Json -Depth 4 -Compress
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event
}
function Send-CategoryHeartbeat {
param(
[string]$Url,
@@ -863,6 +896,9 @@ while ($true) {
$detectedUrl = $null
try {
$context = Get-ForegroundWindowContext
if ($context) {
Send-WindowHeartbeat -Context $context
}
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
if ($url) {