fix(rdp): decouple activity view from disconnected sessions

This commit is contained in:
igor04091968
2026-05-22 20:02:47 +03:00
parent 8af3fd27f4
commit 6cb1e0a95b
4 changed files with 795 additions and 212 deletions
+241 -1
View File
@@ -344,7 +344,16 @@
'.aw-ru-pve-audit-value { font-size: 24px; font-weight: 700; }',
'.aw-ru-pve-audit-table { width: 100%; border-collapse: collapse; margin-top: 8px; }',
'.aw-ru-pve-audit-table th, .aw-ru-pve-audit-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }',
'.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }'
'.aw-ru-pve-audit-muted { opacity: .72; font-size: 13px; }',
'.aw-ru-rdp-center { margin: 16px 0; padding: 16px; border: 1px solid rgba(120,120,120,.35); border-radius: 8px; background: rgba(10,20,40,.04); }',
'.aw-ru-rdp-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0 16px; }',
'.aw-ru-rdp-card { border: 1px solid rgba(120,120,120,.22); border-radius: 8px; padding: 12px; background: rgba(255,255,255,.02); }',
'.aw-ru-rdp-card h5 { margin: 0 0 6px; font-size: 13px; opacity: .8; }',
'.aw-ru-rdp-value { font-size: 24px; font-weight: 700; }',
'.aw-ru-rdp-table { width: 100%; border-collapse: collapse; margin-top: 8px; }',
'.aw-ru-rdp-table th, .aw-ru-rdp-table td { padding: 6px 8px; border-bottom: 1px solid rgba(120,120,120,.18); vertical-align: top; text-align: left; font-size: 13px; }',
'.aw-ru-rdp-links { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }',
'.aw-ru-rdp-links a { display: inline-block; padding: 4px 8px; border-radius: 999px; background: rgba(90,140,255,.15); text-decoration: none; }'
].join("\n");
document.head.appendChild(style);
}
@@ -370,6 +379,12 @@
return "";
}
function getCurrentActivityDayFromHash() {
const hash = window.location.hash || "";
const match = hash.match(/#\/activity\/[^/]+\/day\/([^/?#]+)/i);
return match && match[1] ? decodeURIComponent(match[1]) : "today";
}
function isPveLikeHost(host) {
return /^pve[-_]/i.test(String(host || ""));
}
@@ -384,6 +399,230 @@
return true;
}
function isClientActivityRoute() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i);
if (!match) return false;
const host = decodeURIComponent(match[1] || "");
return isLikelyClientHost(host) && !isPveLikeHost(host);
}
function getRdpReportBaseUrl() {
const url = new URL(window.location.href);
url.hash = "";
url.search = "";
url.pathname = "/reports/worktime/today";
url.port = "5610";
return url;
}
function buildRdpReportUrl(format, day) {
const url = getRdpReportBaseUrl();
url.searchParams.set("day", day || "today");
if (format) url.searchParams.set("format", format);
return url.toString();
}
function normalizeActivityDay(day) {
if (day && day !== "today") return day;
const now = new Date();
return [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0")
].join("-");
}
function getActivityDayRange(day) {
const normalizedDay = normalizeActivityDay(day);
const start = new Date(normalizedDay + "T00:00:00");
const end = new Date(normalizedDay + "T23:59:59");
return { normalizedDay: normalizedDay, start: start, end: end };
}
function formatActiveHhmm(totalSeconds) {
const seconds = Math.max(0, Number(totalSeconds) || 0);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return String(hours).padStart(2, "0") + ":" + String(minutes).padStart(2, "0");
}
function isWorktimeRowActive(data) {
if (!data || typeof data !== "object") return false;
if (typeof data.active === "boolean") return data.active;
const state = String(data.state || "").trim().toLowerCase();
return state === "active" || state === "активно";
}
function formatDurationSeconds(totalSeconds) {
const seconds = Math.max(0, Number(totalSeconds) || 0);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) return hours + "ч " + String(minutes).padStart(2, "0") + "м";
if (minutes > 0) return minutes + "м " + String(secs).padStart(2, "0") + "с";
return secs + "с";
}
function formatIsoForUi(value) {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch (error) {
return value;
}
}
async function fetchRdpWorktimeReport(host, day) {
if (!host) return null;
const cacheKey = host + "|" + (day || "today");
if (!window.__awRuRdpReportCache) window.__awRuRdpReportCache = {};
if (window.__awRuRdpReportCache[cacheKey]) return window.__awRuRdpReportCache[cacheKey];
const range = getActivityDayRange(day);
const bucketId = "aw-worktime-sessions_" + host;
const params = new URLSearchParams();
params.set("start", range.start.toISOString());
params.set("end", new Date(range.end.getTime() + 1000).toISOString());
params.set("limit", "100000");
const response = await fetch("/api/0/buckets/" + encodeURIComponent(bucketId) + "/events?" + params.toString(), { credentials: "same-origin" });
if (!response.ok) throw new Error("rdp-report-fetch-failed");
const events = await response.json();
if (!Array.isArray(events)) return null;
const rowsByUser = new Map();
events.forEach(function (event) {
const data = event && event.data ? event.data : {};
const ts = event && event.timestamp ? String(event.timestamp) : "";
if (!ts) return;
const tsDate = new Date(ts);
if (Number.isNaN(tsDate.getTime())) return;
const tsDay = [
tsDate.getFullYear(),
String(tsDate.getMonth() + 1).padStart(2, "0"),
String(tsDate.getDate()).padStart(2, "0")
].join("-");
if (tsDay !== range.normalizedDay) return;
const userId = String(data.userId || "");
const userName = String(data.username || userId || "").trim();
if (!userName) return;
const key = userId || userName;
if (!rowsByUser.has(key)) {
rowsByUser.set(key, {
user: userName,
user_id: userId || userName,
active_seconds: 0,
first_activity: "",
last_activity: "",
sessions_count: new Set(),
samples_count: 0,
active_samples: 0
});
}
const row = rowsByUser.get(key);
row.samples_count += 1;
if (data.sessionId !== undefined && data.sessionId !== null) row.sessions_count.add(String(data.sessionId));
if (isWorktimeRowActive(data)) {
const sampleSeconds = Math.max(0, Number(data.sampleSeconds || event.duration || 0));
row.active_seconds += sampleSeconds;
row.active_samples += 1;
if (!row.first_activity || ts < row.first_activity) row.first_activity = ts;
if (!row.last_activity || ts > row.last_activity) row.last_activity = ts;
}
});
const payload = {
host: host,
report_date: range.normalizedDay,
rows: Array.from(rowsByUser.values()).map(function (row) {
return {
user: row.user,
user_id: row.user_id,
active_seconds: row.active_seconds,
active_hhmm: formatActiveHhmm(row.active_seconds),
first_activity: row.first_activity,
last_activity: row.last_activity,
sessions_count: row.sessions_count.size,
samples_count: row.samples_count,
active_samples: row.active_samples
};
})
};
window.__awRuRdpReportCache[cacheKey] = payload;
return payload;
}
async function injectRdpWorktimeCenter(root) {
if (!isClientActivityRoute()) return;
const host = getCurrentHostFromHash();
const day = getCurrentActivityDayFromHash();
const report = await fetchRdpWorktimeReport(host, day);
if (!report || !Array.isArray(report.rows) || !report.rows.length) return;
const totalActiveSeconds = report.rows.reduce(function (sum, row) {
return sum + Math.max(0, Number(row && row.active_seconds || 0));
}, 0);
const activeUsers = report.rows.filter(function (row) {
return Number(row && row.active_seconds || 0) > 0;
});
const topRows = activeUsers
.slice()
.sort(function (left, right) {
return Number(right.active_seconds || 0) - Number(left.active_seconds || 0);
})
.slice(0, 5);
Array.from(root.querySelectorAll("li")).forEach(function (item) {
const text = (item.textContent || "").trim();
if (/^(?:Активное время|Time active):/i.test(text)) {
item.textContent = "Активное время: " + formatDurationSeconds(totalActiveSeconds);
}
});
const heading = root.querySelector("h3");
if (!heading || !heading.parentElement) return;
let center = root.querySelector("[data-aw-ru-rdp-center='1']");
if (!center) {
center = document.createElement("section");
center.className = "aw-ru-rdp-center";
center.setAttribute("data-aw-ru-rdp-center", "1");
const anchor = heading.parentElement.querySelector("img") || null;
heading.parentElement.insertBefore(center, anchor);
}
const latestActivity = topRows.reduce(function (latest, row) {
const value = row && row.last_activity ? String(row.last_activity) : "";
if (!value) return latest;
if (!latest) return value;
return value > latest ? value : latest;
}, "");
center.innerHTML =
'<h4>RDP summary</h4>' +
'<p>Этот блок строится из server-side worktime отчёта и не зависит от того, жива ли локальная интерактивная 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>' +
'<section class="aw-ru-rdp-card"><h5>Последняя активность</h5><div class="aw-ru-rdp-value" style="font-size:16px;">' + escapeHtml(formatIsoForUi(latestActivity)) + '</div></section>' +
'</div>' +
'<table class="aw-ru-rdp-table">' +
'<thead><tr><th>Пользователь</th><th>Активное время</th><th>Первая активность</th><th>Последняя активность</th></tr></thead>' +
'<tbody>' +
(topRows.length ? topRows.map(function (row) {
return '<tr>' +
'<td>' + escapeHtml(row.user || row.user_id || "") + '</td>' +
'<td>' + escapeHtml(row.active_hhmm || formatDurationSeconds(row.active_seconds || 0)) + '</td>' +
'<td>' + escapeHtml(formatIsoForUi(row.first_activity || "")) + '</td>' +
'<td>' + escapeHtml(formatIsoForUi(row.last_activity || "")) + '</td>' +
'</tr>';
}).join("") : '<tr><td colspan="4">Нет активных пользователей в отчёте.</td></tr>') +
'</tbody>' +
'</table>' +
'<div class="aw-ru-rdp-links">' +
'<a href="' + escapeHtml(buildRdpReportUrl("html", day)) + '">HTML</a>' +
'<a href="' + escapeHtml(buildRdpReportUrl("csv", day)) + '">CSV</a>' +
'<a href="' + escapeHtml(buildRdpReportUrl("", day)) + '">JSON</a>' +
'</div>';
}
function enforceSafeActivityViewForPveHost() {
const hash = window.location.hash || "";
const match = hash.match(/^#\/activity\/([^/]+)(?:\/day\/([^/]+))?\/view\/([^/?#]+)/i);
@@ -1903,6 +2142,7 @@
staticPatchRouteKey = routeKey;
}
injectPveAuditCenter(document.body);
injectRdpWorktimeCenter(document.body).catch(function () {});
injectDlpNavigation(document.body);
if (isDlpSignalBucketRoute() && dlpOverlayFailureCount === 0) {
try {
+41
View File
@@ -13,11 +13,20 @@ STATE_PATH = os.environ.get(
"/var/lib/activitywatch/aw-worktime-ui-bridge-state.json",
)
TIMEOUT = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_TIMEOUT", "20"))
WATCHER_FALLBACK_ENABLED = os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_FALLBACK", "1").strip().lower() not in {
"0",
"false",
"no",
"off",
}
WATCHER_FALLBACK_STALE_SECONDS = float(os.environ.get("AW_WORKTIME_UI_BRIDGE_WATCHER_STALE_SECONDS", "600"))
SESSIONS_BUCKET = f"aw-worktime-sessions_{HOST}"
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}"
def _req(method: str, path: str, payload=None):
@@ -43,6 +52,31 @@ def ensure_bucket(bucket_id: str, event_type: str, client: str):
raise
def get_latest_bucket_event_ts(bucket_id: str):
try:
events = _req("GET", f"/api/0/buckets/{bucket_id}/events?limit=1") or []
except urllib.error.HTTPError as e:
if e.code == 404:
return None
raise
if not events:
return None
ts = events[0].get("timestamp")
if not ts:
return None
try:
return parse_iso_utc(ts)
except Exception:
return None
def bucket_needs_fallback(bucket_id: str, now_utc: datetime, stale_after_seconds: float):
latest_dt = get_latest_bucket_event_ts(bucket_id)
if latest_dt is None:
return True
return (now_utc - latest_dt).total_seconds() >= stale_after_seconds
def load_state():
try:
with open(STATE_PATH, "r", encoding="utf-8") as f:
@@ -198,6 +232,13 @@ def main():
_req("POST", f"/api/0/buckets/{AFK_BUCKET}/events", afk_events)
_req("POST", f"/api/0/buckets/{WINDOW_BUCKET}/events", win_events)
if WATCHER_FALLBACK_ENABLED:
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):
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})
print(f"posted_afk={len(afk_events)} posted_win={len(win_events)} last_ts={new_last_ts}")
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import importlib.util
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
MODULE_PATH = Path(__file__).with_name("aw-worktime-ui-bridge.py")
SPEC = importlib.util.spec_from_file_location("aw_worktime_ui_bridge", MODULE_PATH)
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
class WorktimeUiBridgeTests(unittest.TestCase):
def test_bucket_needs_fallback_when_bucket_is_missing(self):
now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc)
with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=None):
self.assertTrue(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600))
def test_bucket_needs_fallback_when_bucket_is_recent(self):
now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc)
recent = now - timedelta(seconds=120)
with mock.patch.object(MODULE, "get_latest_bucket_event_ts", return_value=recent):
self.assertFalse(MODULE.bucket_needs_fallback("aw-watcher-window_SHARKON2025", now, 600))
def test_bucket_needs_fallback_when_bucket_is_stale(self):
now = datetime(2026, 5, 22, 16, 0, 0, tzinfo=timezone.utc)
stale = now - timedelta(seconds=601)
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))
if __name__ == "__main__":
unittest.main()