fix(activity): restore app-level window stream on rdp host
This commit is contained in:
@@ -27,6 +27,8 @@ AFK_BUCKET = f"aw-rdp-afk_{HOST}"
|
|||||||
WINDOW_BUCKET = f"aw-rdp-window_{HOST}"
|
WINDOW_BUCKET = f"aw-rdp-window_{HOST}"
|
||||||
WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}"
|
WATCHER_AFK_BUCKET = f"aw-watcher-afk_{HOST}"
|
||||||
WATCHER_WINDOW_BUCKET = f"aw-watcher-window_{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):
|
def _req(method: str, path: str, payload=None):
|
||||||
@@ -52,7 +54,7 @@ def ensure_bucket(bucket_id: str, event_type: str, client: str):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def get_latest_bucket_event_ts(bucket_id: str):
|
def get_latest_bucket_event(bucket_id: str):
|
||||||
try:
|
try:
|
||||||
events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or []
|
events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or []
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
@@ -61,7 +63,14 @@ def get_latest_bucket_event_ts(bucket_id: str):
|
|||||||
raise
|
raise
|
||||||
if not events:
|
if not events:
|
||||||
return None
|
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:
|
if not ts:
|
||||||
return None
|
return None
|
||||||
try:
|
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
|
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():
|
def load_state():
|
||||||
try:
|
try:
|
||||||
with open(STATE_PATH, "r", encoding="utf-8") as f:
|
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)
|
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):
|
def _is_session_active(row_data):
|
||||||
if isinstance(row_data.get("active"), bool):
|
if isinstance(row_data.get("active"), bool):
|
||||||
if row_data.get("active"):
|
if row_data.get("active"):
|
||||||
@@ -136,7 +201,7 @@ def _is_session_active(row_data):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def transform(events):
|
def transform(events, foreground_context=None):
|
||||||
out_afk = []
|
out_afk = []
|
||||||
out_win = []
|
out_win = []
|
||||||
last_ts = None
|
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"}
|
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})
|
out_afk.append({"timestamp": ts, "duration": duration, "data": afk_data})
|
||||||
|
|
||||||
win_data = {
|
if is_active and foreground_context:
|
||||||
"app": "RDP",
|
title = str(foreground_context.get("title") or "").strip()
|
||||||
"title": build_window_title(active_users, active_count),
|
if active_count > 1:
|
||||||
"source": "aw-worktime-ui-bridge",
|
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})
|
out_win.append({"timestamp": ts, "duration": duration, "data": win_data})
|
||||||
last_ts = ts
|
last_ts = ts
|
||||||
|
|
||||||
@@ -226,7 +301,8 @@ def main():
|
|||||||
if not events:
|
if not events:
|
||||||
return
|
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:
|
if not afk_events or not win_events or not new_last_ts:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -236,7 +312,7 @@ def main():
|
|||||||
if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS):
|
if bucket_needs_fallback(WATCHER_AFK_BUCKET, now_utc, WATCHER_FALLBACK_STALE_SECONDS):
|
||||||
ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk")
|
ensure_bucket(WATCHER_AFK_BUCKET, "afkstatus", "aw-watcher-afk")
|
||||||
_req("POST", f"/api/0/buckets/{WATCHER_AFK_BUCKET}/events", afk_events)
|
_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")
|
ensure_bucket(WATCHER_WINDOW_BUCKET, "currentwindow", "aw-watcher-window")
|
||||||
_req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", win_events)
|
_req("POST", f"/api/0/buckets/{WATCHER_WINDOW_BUCKET}/events", win_events)
|
||||||
save_state({"last_ts": new_last_ts})
|
save_state({"last_ts": new_last_ts})
|
||||||
|
|||||||
@@ -30,6 +30,73 @@ class WorktimeUiBridgeTests(unittest.TestCase):
|
|||||||
with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=stale):
|
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))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -526,7 +526,7 @@ function Get-ActivityWatchSessionRecords {
|
|||||||
|
|
||||||
$sessionId = [int]$columns[$sessionIdIndex]
|
$sessionId = [int]$columns[$sessionIdIndex]
|
||||||
$state = if ($columns.Count -gt ($sessionIdIndex + 1)) { [string]$columns[$sessionIdIndex + 1] } else { '' }
|
$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]@{
|
$sessions.Add([pscustomobject]@{
|
||||||
SessionName = $sessionName
|
SessionName = $sessionName
|
||||||
|
|||||||
@@ -780,6 +780,39 @@ function Send-Heartbeat {
|
|||||||
Invoke-AwJsonPost -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -Json $event
|
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 {
|
function Send-CategoryHeartbeat {
|
||||||
param(
|
param(
|
||||||
[string]$Url,
|
[string]$Url,
|
||||||
@@ -863,6 +896,9 @@ while ($true) {
|
|||||||
$detectedUrl = $null
|
$detectedUrl = $null
|
||||||
try {
|
try {
|
||||||
$context = Get-ForegroundWindowContext
|
$context = Get-ForegroundWindowContext
|
||||||
|
if ($context) {
|
||||||
|
Send-WindowHeartbeat -Context $context
|
||||||
|
}
|
||||||
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
|
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
|
||||||
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
|
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
|
||||||
if ($url) {
|
if ($url) {
|
||||||
|
|||||||
Reference in New Issue
Block a user