feat: add filter log by user

This commit is contained in:
2026-07-12 19:57:55 +07:00
parent 15a3f5e742
commit 83e7863fd9
4 changed files with 103 additions and 5 deletions
@@ -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 (
<div className="">
<Card>
<div className="flex items-center justify-end px-4 pt-3 pb-2">
<div className="flex flex-wrap items-center justify-end gap-2 px-4 pt-3 pb-2">
<label className="flex items-center gap-2 text-xs text-text-muted">
<span>User</span>
<select
value={selectedUserId}
onChange={(event) => setSelectedUserId(event.target.value)}
className="rounded-md border border-border bg-bg-subtle px-2 py-1.5 text-xs text-text focus:border-primary focus:outline-none"
>
<option value="">All users</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.username}
</option>
))}
</select>
</label>
<Button size="sm" variant="outline" icon="delete" onClick={handleClear}>
Clear
</Button>
@@ -82,9 +121,11 @@ export default function ConsoleLogClient() {
>
{logs.length === 0 ? (
<span className="text-text-muted">No console logs yet.</span>
) : filteredLogs.length === 0 ? (
<span className="text-text-muted">No console logs for the selected user.</span>
) : (
<div className="space-y-0.5">
{logs.map((line, i) => (
{filteredLogs.map((line, i) => (
<div key={i}>{colorLine(line)}</div>
))}
</div>
+8 -2
View File
@@ -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);
+38
View File
@@ -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;
}
+13
View File
@@ -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);