diff --git a/packages/admin-ui/src/App.tsx b/packages/admin-ui/src/App.tsx index eb8870820..f51ee0027 100644 --- a/packages/admin-ui/src/App.tsx +++ b/packages/admin-ui/src/App.tsx @@ -19,6 +19,8 @@ import { AppContextIface, Theme } from "types"; import Smooth from "components/Smooth"; import { PwaPermissionsAlert, PwaPermissionsModal } from "components/PwaPermissions"; import { LocalProxyBanner } from "pages/wifi/components/localProxying/LocalProxyBanner"; +// Hooks +import { useUiActivityTracker } from "hooks/useUiActivityTracker"; export const AppContext = React.createContext({ theme: "light", @@ -55,6 +57,9 @@ function MainApp({ username }: { username: string }) { const [screenWidth, setScreenWidth] = useState(window.innerWidth); const [theme, setTheme] = useLocalStorage("theme", "light"); + // Track UI activity for Prometheus metrics + useUiActivityTracker(); + useEffect(() => { const handleResize = () => setScreenWidth(window.innerWidth); window.addEventListener("resize", handleResize); diff --git a/packages/admin-ui/src/__mock-backend__/index.ts b/packages/admin-ui/src/__mock-backend__/index.ts index facf1a69e..dfb8cee99 100644 --- a/packages/admin-ui/src/__mock-backend__/index.ts +++ b/packages/admin-ui/src/__mock-backend__/index.ts @@ -427,6 +427,12 @@ export const otherCalls: Omit = { mainnet: { validators: ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"] }, hoodi: { validators: [] }, gnosis: null + }), + uiActivityUpdate: async () => {}, + uiActivityGet: async () => ({ + isActive: true, + lastActivityTimestamp: Math.floor(Date.now() / 1000), + sessionStartTimestamp: Math.floor(Date.now() / 1000) - 3600 }) }; diff --git a/packages/admin-ui/src/hooks/useUiActivityTracker.ts b/packages/admin-ui/src/hooks/useUiActivityTracker.ts new file mode 100644 index 000000000..061d9a943 --- /dev/null +++ b/packages/admin-ui/src/hooks/useUiActivityTracker.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef, useCallback } from "react"; +import { api } from "api"; + +// Configuration +const ACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute - user becomes inactive after this +const HEARTBEAT_INTERVAL_MS = 30 * 1000; // 30 seconds - how often to report activity + +/** + * Hook to track user activity in the UI and report it to the backend. + * Tracks mouse movements, clicks, keyboard input, and touch events. + * Reports activity status every 30 seconds to the backend for Prometheus metrics. + */ +export function useUiActivityTracker(): void { + const isActiveRef = useRef(true); + const lastActivityRef = useRef(Date.now()); + const sessionStartRef = useRef(Math.floor(Date.now() / 1000)); + const activityTimeoutRef = useRef | null>(null); + + // Reset activity timeout on user interaction + const handleActivity = useCallback(() => { + lastActivityRef.current = Date.now(); + isActiveRef.current = true; + + // Clear existing timeout + if (activityTimeoutRef.current) { + clearTimeout(activityTimeoutRef.current); + } + + // Set new timeout to mark user as inactive + activityTimeoutRef.current = setTimeout(() => { + isActiveRef.current = false; + }, ACTIVITY_TIMEOUT_MS); + }, []); + + // Report activity to the backend + const reportActivity = useCallback(async () => { + try { + await api.uiActivityUpdate({ + isActive: isActiveRef.current, + sessionStartTimestamp: sessionStartRef.current + }); + } catch (error) { + console.error("Failed to report UI activity:", error); + } + }, []); + + useEffect(() => { + // Initialize session start time + sessionStartRef.current = Math.floor(Date.now() / 1000); + + // Set up activity event listeners + const events = ["mousedown", "mousemove", "keydown", "touchstart", "scroll", "click"]; + events.forEach((event) => { + document.addEventListener(event, handleActivity, { passive: true }); + }); + + // Initial activity timeout + activityTimeoutRef.current = setTimeout(() => { + isActiveRef.current = false; + }, ACTIVITY_TIMEOUT_MS); + + // Start heartbeat interval to report activity + const heartbeatInterval = setInterval(reportActivity, HEARTBEAT_INTERVAL_MS); + + // Report initial activity + reportActivity(); + + // Cleanup + return () => { + events.forEach((event) => { + document.removeEventListener(event, handleActivity); + }); + + if (activityTimeoutRef.current) { + clearTimeout(activityTimeoutRef.current); + } + + clearInterval(heartbeatInterval); + + // Report inactive status on unmount (session end) + api + .uiActivityUpdate({ + isActive: false, + sessionStartTimestamp: sessionStartRef.current + }) + .catch(console.error); + }; + }, [handleActivity, reportActivity]); +} diff --git a/packages/dappmanager/src/api/routes/metrics.ts b/packages/dappmanager/src/api/routes/metrics.ts index 5d2262fc2..65dadf10f 100644 --- a/packages/dappmanager/src/api/routes/metrics.ts +++ b/packages/dappmanager/src/api/routes/metrics.ts @@ -206,6 +206,61 @@ register.registerMetric( }) ); +// UI activity staleness threshold (if no heartbeat received in this time, consider user inactive) +// Should be > heartbeat interval (30s) to account for network delays +const UI_ACTIVITY_STALE_THRESHOLD_SECONDS = 90; // 1.5 minutes + +// Helper to check if UI activity is stale +function isUiActivityStale(lastActivityTimestamp: number): boolean { + const now = Math.floor(Date.now() / 1000); + return now - lastActivityTimestamp > UI_ACTIVITY_STALE_THRESHOLD_SECONDS; +} + +// UI user active metric +register.registerMetric( + new client.Gauge({ + name: "ui_user_active", + help: "Whether a user is currently active in the UI (1 = active, 0 = inactive)", + collect() { + const uiActivityData = db.uiActivity.get(); + // Consider inactive if no heartbeat received recently (handles browser close) + const isActive = uiActivityData.isActive && !isUiActivityStale(uiActivityData.lastActivityTimestamp); + this.set(isActive ? 1 : 0); + } + }) +); + +// UI last activity timestamp metric +register.registerMetric( + new client.Gauge({ + name: "ui_last_activity_timestamp_seconds", + help: "Unix timestamp of the last user activity in the UI (seconds)", + collect() { + const uiActivityData = db.uiActivity.get(); + this.set(uiActivityData.lastActivityTimestamp); + } + }) +); + +// UI session uptime metric +register.registerMetric( + new client.Gauge({ + name: "ui_session_uptime_seconds", + help: "Duration of the current UI session in seconds (0 if no active session)", + collect() { + const uiActivityData = db.uiActivity.get(); + // Only report uptime if session is active and not stale + const isActive = uiActivityData.isActive && !isUiActivityStale(uiActivityData.lastActivityTimestamp); + if (uiActivityData.sessionStartTimestamp > 0 && isActive) { + const now = Math.floor(Date.now() / 1000); + this.set(now - uiActivityData.sessionStartTimestamp); + } else { + this.set(0); + } + } + }) +); + // Add a default label which is added to all metrics register.setDefaultLabels({ app: "dappmanager-custom-metrics" diff --git a/packages/dappmanager/src/calls/index.ts b/packages/dappmanager/src/calls/index.ts index 085209eeb..ce3e224ce 100644 --- a/packages/dappmanager/src/calls/index.ts +++ b/packages/dappmanager/src/calls/index.ts @@ -93,6 +93,7 @@ export { statsDiskGet } from "./statsDiskGet.js"; export { systemInfoGet } from "./systemInfoGet.js"; export { telegramConfigGet, telegramConfigSet, telegramStatusGet, telegramStatusSet } from "./telegram.js"; export { updateUpgrade } from "./updateUpgrade.js"; +export { uiActivityUpdate, uiActivityGet } from "./uiActivityUpdate.js"; export { natRenewalIsEnabled, natRenewalEnable } from "./natRenewal.js"; export { volumeRemove } from "./volumeRemove.js"; export { volumesGet } from "./volumesGet.js"; diff --git a/packages/dappmanager/src/calls/uiActivityUpdate.ts b/packages/dappmanager/src/calls/uiActivityUpdate.ts new file mode 100644 index 000000000..7e729d950 --- /dev/null +++ b/packages/dappmanager/src/calls/uiActivityUpdate.ts @@ -0,0 +1,30 @@ +import * as db from "@dappnode/db"; + +/** + * Updates the UI activity metrics. + * Called periodically from the admin-ui to report user activity. + * @param isActive Whether the user is currently active + * @param sessionStartTimestamp When the current session started (Unix epoch seconds) + */ +export async function uiActivityUpdate({ + isActive, + sessionStartTimestamp +}: { + isActive: boolean; + sessionStartTimestamp: number; +}): Promise { + const now = Math.floor(Date.now() / 1000); + + db.uiActivity.set({ + isActive, + lastActivityTimestamp: now, + sessionStartTimestamp + }); +} + +/** + * Gets the current UI activity metrics. + */ +export async function uiActivityGet(): Promise { + return db.uiActivity.get(); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 4c8d962ba..08090af7a 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -14,6 +14,7 @@ export * from "./stakerConfig.js"; export * from "./system.js"; export * from "./systemFlags.js"; export * from "./ui.js"; +export * from "./uiActivity.js"; export * from "./upnp.js"; export * from "./vpn.js"; export * from "./stakerConfig.js"; diff --git a/packages/db/src/uiActivity.ts b/packages/db/src/uiActivity.ts new file mode 100644 index 000000000..6f62109a0 --- /dev/null +++ b/packages/db/src/uiActivity.ts @@ -0,0 +1,20 @@ +import { dbCache } from "./dbFactory.js"; + +const UI_ACTIVITY = "ui-activity"; + +export interface UiActivityData { + /** Whether the user is currently active in the UI */ + isActive: boolean; + /** Timestamp of last user activity in seconds (Unix epoch) */ + lastActivityTimestamp: number; + /** Timestamp when the session started in seconds (Unix epoch) */ + sessionStartTimestamp: number; +} + +const defaultUiActivity: UiActivityData = { + isActive: false, + lastActivityTimestamp: 0, + sessionStartTimestamp: 0 +}; + +export const uiActivity = dbCache.staticKey(UI_ACTIVITY, defaultUiActivity); diff --git a/packages/types/src/calls.ts b/packages/types/src/calls.ts index fb7b70102..efee12049 100644 --- a/packages/types/src/calls.ts +++ b/packages/types/src/calls.ts @@ -1044,6 +1044,21 @@ export interface IdentityInterface { publicKey: string; } +/** + * =========== + * UI ACTIVITY + * =========== + */ + +export interface UiActivityData { + /** Whether the user is currently active in the UI */ + isActive: boolean; + /** Timestamp of last user activity in seconds (Unix epoch) */ + lastActivityTimestamp: number; + /** Timestamp when the session started in seconds (Unix epoch) */ + sessionStartTimestamp: number; +} + /** * ===== * UTILS diff --git a/packages/types/src/routes.ts b/packages/types/src/routes.ts index ab245403c..2507441d5 100644 --- a/packages/types/src/routes.ts +++ b/packages/types/src/routes.ts @@ -39,7 +39,8 @@ import { WifiReport, WireguardDeviceCredentials, DockerUpgradeRequirements, - InstalledPackageData + InstalledPackageData, + UiActivityData } from "./calls.js"; import { PackageEnvs } from "./compose.js"; import { PackageBackup } from "./manifest.js"; @@ -834,6 +835,19 @@ export interface Routes { /** Get URLs to a single Wireguard credentials */ wireguardDevicesGet(): Promise; + + /** + * Updates the UI activity metrics. + * Called periodically from the admin-ui to report user activity. + * @param isActive Whether the user is currently active + * @param sessionStartTimestamp When the current session started (Unix epoch seconds) + */ + uiActivityUpdate: (kwargs: { isActive: boolean; sessionStartTimestamp: number }) => Promise; + + /** + * Gets the current UI activity metrics. + */ + uiActivityGet: () => Promise; } interface RouteData { @@ -978,7 +992,9 @@ export const routesData: { [P in keyof Routes]: RouteData } = { wireguardDeviceAdd: { log: true }, wireguardDeviceRemove: { log: true }, wireguardDeviceGet: {}, - wireguardDevicesGet: {} + wireguardDevicesGet: {}, + uiActivityUpdate: {}, + uiActivityGet: {} }; // DO NOT REMOVE