From 83e7863fd9dc217efd4483b62a17058eb3a2bbd1 Mon Sep 17 00:00:00 2001 From: Loi Phan Date: Sun, 12 Jul 2026 19:57:55 +0700 Subject: [PATCH] feat: add filter log by user --- .../dashboard/console-log/ConsoleLogClient.js | 47 +++++++++++++++++-- src/lib/consoleLogBuffer.js | 10 +++- src/lib/requestContext.js | 38 +++++++++++++++ src/sse/handlers/chat.js | 13 +++++ 4 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 src/lib/requestContext.js diff --git a/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js b/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js index 01a8a757..256dd5aa 100644 --- a/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js +++ b/src/app/(dashboard)/dashboard/console-log/ConsoleLogClient.js @@ -22,8 +22,14 @@ function colorLine(line) { export default function ConsoleLogClient() { const [logs, setLogs] = useState([]); const [connected, setConnected] = useState(false); + const [users, setUsers] = useState([]); + const [selectedUserId, setSelectedUserId] = useState(""); const logRef = useRef(null); + const filteredLogs = selectedUserId + ? logs.filter((line) => line.includes(`[USER:${selectedUserId}]`)) + : logs; + const handleClear = async () => { try { await fetch("/api/translator/console-logs", { method: "DELETE" }); @@ -62,16 +68,49 @@ export default function ConsoleLogClient() { return () => es.close(); }, []); + useEffect(() => { + const controller = new AbortController(); + + async function loadUsers() { + try { + const response = await fetch("/api/users", { signal: controller.signal }); + if (!response.ok) return; + const data = await response.json(); + setUsers((data.users || []).filter((user) => user.isActive)); + } catch (error) { + if (error.name !== "AbortError") console.error("Failed to load users:", error); + } + } + + loadUsers(); + return () => controller.abort(); + }, []); + // Auto-scroll to bottom on new logs useEffect(() => { if (!logRef.current) return; logRef.current.scrollTop = logRef.current.scrollHeight; - }, [logs]); + }, [filteredLogs]); return (
-
+
+ @@ -82,9 +121,11 @@ export default function ConsoleLogClient() { > {logs.length === 0 ? ( No console logs yet. + ) : filteredLogs.length === 0 ? ( + No console logs for the selected user. ) : (
- {logs.map((line, i) => ( + {filteredLogs.map((line, i) => (
{colorLine(line)}
))}
diff --git a/src/lib/consoleLogBuffer.js b/src/lib/consoleLogBuffer.js index 9ace30fb..76356d0f 100644 --- a/src/lib/consoleLogBuffer.js +++ b/src/lib/consoleLogBuffer.js @@ -1,5 +1,6 @@ import { EventEmitter } from "events"; import { CONSOLE_LOG_CONFIG } from "@/shared/constants/config.js"; +import { getRequestUser } from "@/lib/requestContext"; const consoleLevels = ["log", "info", "warn", "error", "debug"]; @@ -63,12 +64,17 @@ function formatArg(arg) { } function appendLine(line) { - state.logs.push(line); + const requestUser = getRequestUser(); + const attributedLine = requestUser + ? `[USER:${requestUser.id}] [${requestUser.username}] ${line}` + : line; + + state.logs.push(attributedLine); const maxLines = CONSOLE_LOG_CONFIG.maxLines; if (state.logs.length > maxLines) { state.logs = state.logs.slice(-maxLines); } - state.pendingLines.push(line); + state.pendingLines.push(attributedLine); if (state.pendingLines.length >= MAX_BATCH_LINES) { if (state.flushTimer) { clearTimeout(state.flushTimer); diff --git a/src/lib/requestContext.js b/src/lib/requestContext.js new file mode 100644 index 00000000..eb02331b --- /dev/null +++ b/src/lib/requestContext.js @@ -0,0 +1,38 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +if (!global._requestUserContext) { + global._requestUserContext = new AsyncLocalStorage(); +} + +const requestContext = global._requestUserContext; + +function normalizeUser(user) { + if (!user?.id || !user?.username) return null; + return { id: String(user.id), username: String(user.username) }; +} + +/** + * Execute an async request handler with its dashboard user available to + * console-log capture. AsyncLocalStorage keeps concurrent request contexts + * isolated while their asynchronous work is in flight. + */ +export function runWithRequestUser(user, handler) { + return requestContext.run({ user: normalizeUser(user) }, handler); +} + +/** + * Update the user associated with the active request, for example after the + * selected provider connection resolves its owner. + */ +export function setRequestUser(user) { + const store = requestContext.getStore(); + if (store) { + store.user = normalizeUser(user); + return; + } + requestContext.enterWith({ user: normalizeUser(user) }); +} + +export function getRequestUser() { + return requestContext.getStore()?.user || null; +} diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 0d3bafb1..96744c5e 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -10,6 +10,8 @@ import { } from "../services/auth.js"; import { cacheClaudeHeaders } from "open-sse/utils/claudeHeaderCache.js"; import { getSettings } from "@/lib/localDb"; +import { getUserById } from "@/lib/db"; +import { runWithRequestUser, setRequestUser } from "@/lib/requestContext"; import { getModelInfo, getCombo } from "../services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect"; @@ -58,6 +60,9 @@ export async function handleChat(request, clientRawRequest = null) { const authHeader = request.headers.get("Authorization"); const apiKey = extractApiKey(request); const ownerId = await getApiKeyOwnerId(apiKey); + const requestUser = ownerId ? await getUserById(ownerId) : null; + + return runWithRequestUser(requestUser, async () => { if (authHeader && apiKey) { const masked = log.maskKey(apiKey); log.debug("AUTH", `API Key: ${masked}`); @@ -135,6 +140,7 @@ export async function handleChat(request, clientRawRequest = null) { // Single model request return handleSingleModelChat(body, modelStr, clientRawRequest, request, apiKey, ownerId); + }); } /** @@ -226,6 +232,13 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); } + // Requests without an API key inherit the selected provider connection's + // owner, matching usage-history attribution. + if (!ownerId && credentials._connection?.ownerId) { + const connectionOwner = await getUserById(credentials._connection.ownerId); + setRequestUser(connectionOwner); + } + // Account selection shown in the unified "▶" line (acc:...) const refreshedCredentials = await checkAndRefreshToken(provider, credentials);