Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/admin-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppContextIface>({
theme: "light",
Expand Down Expand Up @@ -55,6 +57,9 @@ function MainApp({ username }: { username: string }) {
const [screenWidth, setScreenWidth] = useState(window.innerWidth);
const [theme, setTheme] = useLocalStorage<Theme>("theme", "light");

// Track UI activity for Prometheus metrics
useUiActivityTracker();

useEffect(() => {
const handleResize = () => setScreenWidth(window.innerWidth);
window.addEventListener("resize", handleResize);
Expand Down
6 changes: 6 additions & 0 deletions packages/admin-ui/src/__mock-backend__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,12 @@ export const otherCalls: Omit<Routes, keyof typeof namedSpacedCalls> = {
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
})
};

Expand Down
89 changes: 89 additions & 0 deletions packages/admin-ui/src/hooks/useUiActivityTracker.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof setTimeout> | 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]);
}
55 changes: 55 additions & 0 deletions packages/dappmanager/src/api/routes/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions packages/dappmanager/src/calls/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
30 changes: 30 additions & 0 deletions packages/dappmanager/src/calls/uiActivityUpdate.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<db.UiActivityData> {
return db.uiActivity.get();
}
1 change: 1 addition & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
20 changes: 20 additions & 0 deletions packages/db/src/uiActivity.ts
Original file line number Diff line number Diff line change
@@ -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<UiActivityData>(UI_ACTIVITY, defaultUiActivity);
15 changes: 15 additions & 0 deletions packages/types/src/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions packages/types/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ import {
WifiReport,
WireguardDeviceCredentials,
DockerUpgradeRequirements,
InstalledPackageData
InstalledPackageData,
UiActivityData
} from "./calls.js";
import { PackageEnvs } from "./compose.js";
import { PackageBackup } from "./manifest.js";
Expand Down Expand Up @@ -834,6 +835,19 @@ export interface Routes {

/** Get URLs to a single Wireguard credentials */
wireguardDevicesGet(): Promise<string[]>;

/**
* 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<void>;

/**
* Gets the current UI activity metrics.
*/
uiActivityGet: () => Promise<UiActivityData>;
}

interface RouteData {
Expand Down Expand Up @@ -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
Expand Down
Loading