feat(DetailLogs): viewer
This commit is contained in:
parent
97376a9cb6
commit
c1f59a6e6a
12 changed files with 736 additions and 48 deletions
|
|
@ -26,7 +26,7 @@ scripts/ 개발 스크립트
|
|||
|
||||
## Key Concepts
|
||||
|
||||
**인증**: `Authorization: Bearer <api_key>` → `auth.ts` 미들웨어 → 사용자 식별 + 권한 로드
|
||||
**인증**: `Authorization: Bearer <api_key>` → `auth.ts` 미들웨어 → 사용자 식별 + 권한 로드. 이 키는 라우터 접속용이며, 업스트림 요청에는 전달되지 않는다. 업스트림 `Authorization`은 `backends.api_key`가 있을 때만 해당 값으로 주입된다.
|
||||
|
||||
**요청 흐름**:
|
||||
```
|
||||
|
|
@ -38,6 +38,8 @@ Client → Auth → Script(onRequest) → RouterService → Backend → Script(o
|
|||
|
||||
**Database**: `DB_DIR` 하위에 `core.db` (users, backends, permissions, user_scripts), `analytics.db` (usage_stats, backend_metrics), `request_logs/request_logs_YYYY-MM.db` (상세 요청 로그)
|
||||
|
||||
**상세 로그 조회**: `month` 또는 `date`를 지정하면 해당 월 DB 1개만 조회한다. 둘 다 없으면 최신 월부터 월별 DB를 순차 조회하며 `offset`/`limit`을 적용한다.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Users } from './routes/Users';
|
|||
import { Backends } from './routes/Backends';
|
||||
import { Permissions } from './routes/Permissions';
|
||||
import { Analytics } from './routes/Analytics';
|
||||
import { DetailLogs } from './routes/DetailLogs';
|
||||
import { Scripts } from './routes/Scripts';
|
||||
|
||||
export default function App() {
|
||||
|
|
@ -14,6 +15,7 @@ export default function App() {
|
|||
<Route path="/backends" component={Backends} />
|
||||
<Route path="/permissions" component={Permissions} />
|
||||
<Route path="/analytics" component={Analytics} />
|
||||
<Route path="/detail-logs" component={DetailLogs} />
|
||||
<Route path="/scripts" component={Scripts} />
|
||||
</Router>
|
||||
);
|
||||
|
|
|
|||
269
client/src/routes/DetailLogs.tsx
Normal file
269
client/src/routes/DetailLogs.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { createMemo, createResource, createSignal, Show, type Component } from 'solid-js';
|
||||
import { RefreshCcw } from 'lucide-solid';
|
||||
import { api } from '../api/client';
|
||||
import { Layout } from '../components/Layout';
|
||||
import type { RequestLog } from '../types';
|
||||
import { Button, CommandBar, CommandBarGroup, ConversationTimeline, DataGrid, EmptyState, MetaCluster, PageHeader, Panel, Select, StatusBadge, SummaryStrip, Tabs, TextField, hasRenderableConversation } from '../ui';
|
||||
|
||||
interface FilterState {
|
||||
month: string;
|
||||
date: string;
|
||||
q: string;
|
||||
userId: string;
|
||||
backendId: string;
|
||||
endpoint: string;
|
||||
detailLogged: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
||||
|
||||
const emptyFilters = (): FilterState => ({
|
||||
month: '',
|
||||
date: '',
|
||||
q: '',
|
||||
userId: '',
|
||||
backendId: '',
|
||||
endpoint: '',
|
||||
detailLogged: '',
|
||||
});
|
||||
|
||||
function prettyPrint(value?: string): string {
|
||||
if (!value) return '';
|
||||
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const DetailLogs: Component = () => {
|
||||
const [filters, setFilters] = createSignal<FilterState>(emptyFilters());
|
||||
const [page, setPage] = createSignal(1);
|
||||
const [pageSize, setPageSize] = createSignal(25);
|
||||
const [selectedLogId, setSelectedLogId] = createSignal<number | null>(null);
|
||||
|
||||
const [users] = createResource(() => api.users.getAll());
|
||||
const [backends] = createResource(() => api.backends.getAll());
|
||||
const [logs, { refetch }] = createResource(
|
||||
() => ({
|
||||
...filters(),
|
||||
page: page(),
|
||||
pageSize: pageSize(),
|
||||
}),
|
||||
async (params) =>
|
||||
api.analytics.getRequests({
|
||||
limit: params.pageSize,
|
||||
offset: (params.page - 1) * params.pageSize,
|
||||
month: params.month || undefined,
|
||||
date: params.date || undefined,
|
||||
q: params.q || undefined,
|
||||
userId: params.userId ? Number(params.userId) : undefined,
|
||||
backendId: params.backendId ? Number(params.backendId) : undefined,
|
||||
endpoint: params.endpoint || undefined,
|
||||
detailLogged: params.detailLogged === '' ? undefined : params.detailLogged === 'true',
|
||||
})
|
||||
);
|
||||
|
||||
const requestRows = createMemo(() => logs() ?? []);
|
||||
const selectedLog = createMemo<RequestLog | undefined>(() => requestRows().find((row) => row.id === selectedLogId()));
|
||||
const selectedLogHasConversation = createMemo(() =>
|
||||
selectedLog() ? hasRenderableConversation(selectedLog()!.request_body, selectedLog()!.response_body) : false
|
||||
);
|
||||
|
||||
const userOptions = createMemo(() => [
|
||||
{ value: '', label: 'All users' },
|
||||
...((users() ?? []).map((user) => ({ value: String(user.id), label: `${user.id} · ${user.name}` }))),
|
||||
]);
|
||||
|
||||
const backendOptions = createMemo(() => [
|
||||
{ value: '', label: 'All backends' },
|
||||
...((backends() ?? []).map((backend) => ({ value: String(backend.id), label: `${backend.id} · ${backend.name}` }))),
|
||||
]);
|
||||
|
||||
const endpointOptions = [
|
||||
{ value: '', label: 'All endpoints' },
|
||||
{ value: '/v1/chat/completions', label: '/v1/chat/completions' },
|
||||
];
|
||||
|
||||
const detailOptions = [
|
||||
{ value: '', label: 'All logs' },
|
||||
{ value: 'true', label: 'Verbose only' },
|
||||
{ value: 'false', label: 'Metadata only' },
|
||||
];
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters(emptyFilters());
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const updateFilter = (key: keyof FilterState, value: string) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div class="ui-app-page">
|
||||
<PageHeader
|
||||
title="Detail Logs"
|
||||
description="Inspect verbose request logs with monthly filters, text search, and full request/response payload views."
|
||||
actions={<Button onClick={() => void refetch()}><RefreshCcw />Refresh</Button>}
|
||||
/>
|
||||
|
||||
<SummaryStrip
|
||||
items={[
|
||||
{ label: 'Rows Loaded', value: requestRows().length, hint: `Page ${page()} · ${pageSize()} / page` },
|
||||
{ label: 'Verbose Rows', value: requestRows().filter((row) => row.detail_logged).length, hint: 'Current result set' },
|
||||
{ label: 'Selected Log', value: selectedLog()?.id ?? '-', hint: selectedLog() ? 'Focused inspector row' : 'No selection' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<CommandBar>
|
||||
<CommandBarGroup>
|
||||
<TextField
|
||||
label="Search"
|
||||
value={filters().q}
|
||||
placeholder="Search body, headers, models, errors"
|
||||
onInput={(event) => updateFilter('q', event.currentTarget.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Month"
|
||||
value={filters().month}
|
||||
placeholder="YYYY-MM"
|
||||
onInput={(event) => updateFilter('month', event.currentTarget.value)}
|
||||
/>
|
||||
<TextField
|
||||
label="Date"
|
||||
value={filters().date}
|
||||
placeholder="YYYY-MM-DD"
|
||||
onInput={(event) => updateFilter('date', event.currentTarget.value)}
|
||||
/>
|
||||
</CommandBarGroup>
|
||||
<CommandBarGroup>
|
||||
<Select label="User" value={filters().userId} options={userOptions()} onChange={(value) => updateFilter('userId', value)} />
|
||||
<Select label="Backend" value={filters().backendId} options={backendOptions()} onChange={(value) => updateFilter('backendId', value)} />
|
||||
<Select label="Endpoint" value={filters().endpoint} options={endpointOptions} onChange={(value) => updateFilter('endpoint', value)} />
|
||||
<Select label="Detail" value={filters().detailLogged} options={detailOptions} onChange={(value) => updateFilter('detailLogged', value)} />
|
||||
<Button onClick={resetFilters}>Reset</Button>
|
||||
</CommandBarGroup>
|
||||
</CommandBar>
|
||||
|
||||
<div class="ui-section-grid">
|
||||
<Panel title="Log Results" description="Monthly request log rows. Select one to inspect full payload snapshots.">
|
||||
<DataGrid
|
||||
rows={requestRows()}
|
||||
columns={[
|
||||
{ id: 'id', header: 'ID', mono: true, cell: (row) => <span>{row.id}</span> },
|
||||
{ id: 'created_at', header: 'UTC Time', cell: (row) => <span>{new Date(row.created_at).toLocaleString()}</span> },
|
||||
{ id: 'user_id', header: 'User', mono: true, cell: (row) => <span>{row.user_id}</span> },
|
||||
{ id: 'backend_id', header: 'Backend', mono: true, cell: (row) => <span>{row.backend_id}</span> },
|
||||
{ id: 'request_model', header: 'Model', truncate: true, cell: (row) => <span title={row.request_model ?? '-'}>{row.request_model || '-'}</span> },
|
||||
{
|
||||
id: 'status_code',
|
||||
header: 'Status',
|
||||
cell: (row) => <StatusBadge tone={row.status_code >= 400 ? 'danger' : 'success'}>{String(row.status_code)}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'detail_logged',
|
||||
header: 'Detail',
|
||||
cell: (row) => <StatusBadge tone={row.detail_logged ? 'warning' : 'neutral'}>{row.detail_logged ? 'Verbose' : 'Meta'}</StatusBadge>,
|
||||
},
|
||||
]}
|
||||
getRowKey={(row) => row.id}
|
||||
loading={logs.loading}
|
||||
emptyMessage="No detailed logs matched the current filters."
|
||||
onRowClick={(row) => setSelectedLogId(row.id)}
|
||||
pagination={{
|
||||
page: page(),
|
||||
pageSize: pageSize(),
|
||||
total: page() * pageSize() + (requestRows().length === pageSize() ? 1 : 0),
|
||||
onPageChange: (nextPage) => setPage(nextPage),
|
||||
onPageSizeChange: (nextPageSize) => {
|
||||
setPageSize(nextPageSize);
|
||||
setPage(1);
|
||||
},
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
}}
|
||||
/>
|
||||
{!logs.loading && requestRows().length === 0 && (
|
||||
<EmptyState title="No logs found" description="Try a different month, date, or search term." />
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Selected Log" description="Expanded metadata and serialized request/response snapshots for the active row.">
|
||||
<Show
|
||||
when={selectedLog()}
|
||||
fallback={<EmptyState title="No log selected" description="Select a row from the log table to inspect the request and response snapshots." />}
|
||||
>
|
||||
{(log) => (
|
||||
<div class="ui-stack">
|
||||
<MetaCluster
|
||||
items={[
|
||||
{ key: 'ID', value: String(log().id) },
|
||||
{ key: 'Local Date', value: log().local_date },
|
||||
{ key: 'User', value: String(log().user_id) },
|
||||
{ key: 'Backend', value: String(log().backend_id) },
|
||||
{ key: 'Endpoint', value: log().endpoint },
|
||||
{ key: 'Status', value: String(log().status_code) },
|
||||
{ key: 'Latency', value: `${log().response_time_ms ?? 0}ms` },
|
||||
{ key: 'Verbose', value: log().detail_logged ? 'Yes' : 'No' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Show when={log().error_message}>
|
||||
<TextField label="Error" value={log().error_message ?? ''} multiline />
|
||||
</Show>
|
||||
|
||||
<Tabs.Root defaultValue={selectedLogHasConversation() ? 'conversation' : 'request'}>
|
||||
<Tabs.List aria-label="Detail log inspector">
|
||||
<Show when={selectedLogHasConversation()}>
|
||||
<Tabs.Trigger value="conversation">Conversation</Tabs.Trigger>
|
||||
</Show>
|
||||
<Tabs.Trigger value="request">Request</Tabs.Trigger>
|
||||
<Tabs.Trigger value="response">Response</Tabs.Trigger>
|
||||
<Tabs.Trigger value="raw">Raw</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Show when={selectedLogHasConversation()}>
|
||||
<Tabs.Content value="conversation">
|
||||
<ConversationTimeline
|
||||
requestBody={log().request_body}
|
||||
responseBody={log().response_body}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</Show>
|
||||
|
||||
<Tabs.Content value="request">
|
||||
<div class="ui-stack">
|
||||
<TextField label="Request Headers" value={prettyPrint(log().request_headers)} multiline />
|
||||
<TextField label="Request Body" value={prettyPrint(log().request_body)} multiline />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="response">
|
||||
<div class="ui-stack">
|
||||
<TextField label="Response Headers" value={prettyPrint(log().response_headers)} multiline />
|
||||
<TextField label="Response Body" value={prettyPrint(log().response_body)} multiline />
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="raw">
|
||||
<div class="ui-stack">
|
||||
<TextField
|
||||
label="Raw Log JSON"
|
||||
value={JSON.stringify(log(), null, 2)}
|
||||
multiline
|
||||
/>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ export * from './primitives/Toast';
|
|||
export * from './primitives/Tooltip';
|
||||
export * from './patterns/CommandBar';
|
||||
export * from './patterns/ConfirmDialog';
|
||||
export * from './patterns/ConversationTimeline';
|
||||
export * from './patterns/DataGrid';
|
||||
export * from './patterns/EmptyState';
|
||||
export * from './patterns/FieldRow';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { A, useLocation } from '@solidjs/router';
|
||||
import { ChartColumn, FileCode, LayoutDashboard, Moon, Server, ShieldCheck, Sun, Users } from 'lucide-solid';
|
||||
import { ChartColumn, FileCode, LayoutDashboard, Logs, Moon, Server, ShieldCheck, Sun, Users } from 'lucide-solid';
|
||||
import { For, createMemo, createSignal, onCleanup, onMount, type JSX, type ParentComponent } from 'solid-js';
|
||||
import SnakegroundBg from '../../components/SnakegroundBg';
|
||||
import { IconButton } from '../primitives/IconButton';
|
||||
|
|
@ -12,6 +12,7 @@ const navItems = [
|
|||
{ path: '/backends', label: 'Backends', icon: Server },
|
||||
{ path: '/permissions', label: 'Permissions', icon: ShieldCheck },
|
||||
{ path: '/analytics', label: 'Analytics', icon: ChartColumn },
|
||||
{ path: '/detail-logs', label: 'Detail Logs', icon: Logs },
|
||||
{ path: '/scripts', label: 'Scripts', icon: FileCode },
|
||||
];
|
||||
|
||||
|
|
|
|||
177
client/src/ui/patterns/ConversationTimeline.tsx
Normal file
177
client/src/ui/patterns/ConversationTimeline.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import { For, Show, createMemo } from 'solid-js';
|
||||
import { MetaCluster } from './MetaCluster';
|
||||
import { StatusBadge, type StatusTone } from './StatusBadge';
|
||||
|
||||
type KnownChatRole = 'system' | 'user' | 'assistant';
|
||||
|
||||
interface ParsedMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
interface ConversationTimelineProps {
|
||||
requestBody?: unknown;
|
||||
responseBody?: unknown;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
function normalizePayload(value: unknown): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function normalizeMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
|
||||
const messages = payload?.messages;
|
||||
if (!Array.isArray(messages)) return [];
|
||||
|
||||
return messages
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
role: typeof item.role === 'string' ? item.role : 'unknown',
|
||||
content: typeof item.content === 'string' ? item.content : JSON.stringify(item.content ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeAssistantMessages(payload: Record<string, unknown> | null): ParsedMessage[] {
|
||||
const choices = payload?.choices;
|
||||
if (!Array.isArray(choices)) return [];
|
||||
|
||||
const messages: Array<ParsedMessage | null> = choices.map((choice) => {
|
||||
if (!choice || typeof choice !== 'object') return null;
|
||||
const message = (choice as Record<string, unknown>).message;
|
||||
if (!message || typeof message !== 'object') return null;
|
||||
|
||||
const content = typeof (message as Record<string, unknown>).content === 'string'
|
||||
? String((message as Record<string, unknown>).content)
|
||||
: JSON.stringify((message as Record<string, unknown>).content ?? '');
|
||||
const metadata = [
|
||||
(message as Record<string, unknown>).reasoning_content !== undefined
|
||||
? {
|
||||
key: 'Reasoning',
|
||||
value:
|
||||
typeof (message as Record<string, unknown>).reasoning_content === 'string'
|
||||
? String((message as Record<string, unknown>).reasoning_content)
|
||||
: JSON.stringify((message as Record<string, unknown>).reasoning_content),
|
||||
}
|
||||
: null,
|
||||
(message as Record<string, unknown>).tool_calls !== undefined
|
||||
? {
|
||||
key: 'Tool Calls',
|
||||
value: JSON.stringify((message as Record<string, unknown>).tool_calls),
|
||||
}
|
||||
: null,
|
||||
(choice as Record<string, unknown>).finish_reason !== undefined
|
||||
? { key: 'Finish', value: String((choice as Record<string, unknown>).finish_reason) }
|
||||
: null,
|
||||
(choice as Record<string, unknown>).matched_stop !== undefined
|
||||
? { key: 'Matched Stop', value: String((choice as Record<string, unknown>).matched_stop) }
|
||||
: null,
|
||||
(choice as Record<string, unknown>).logprobs !== undefined
|
||||
? { key: 'Logprobs', value: JSON.stringify((choice as Record<string, unknown>).logprobs) }
|
||||
: null,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
|
||||
return {
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
metadata,
|
||||
};
|
||||
});
|
||||
|
||||
return messages.filter((message): message is ParsedMessage => message !== null);
|
||||
}
|
||||
|
||||
export function hasRenderableConversation(requestBody?: unknown, responseBody?: unknown): boolean {
|
||||
const requestMessages = normalizeMessages(normalizePayload(requestBody));
|
||||
const responseMessages = normalizeAssistantMessages(normalizePayload(responseBody));
|
||||
return requestMessages.length > 0 || responseMessages.length > 0;
|
||||
}
|
||||
|
||||
const roleTone: Record<KnownChatRole, StatusTone> = {
|
||||
system: 'info',
|
||||
user: 'warning',
|
||||
assistant: 'success',
|
||||
};
|
||||
|
||||
function getRoleTone(role: string): StatusTone {
|
||||
if (role === 'system' || role === 'user' || role === 'assistant') {
|
||||
return roleTone[role];
|
||||
}
|
||||
return 'neutral';
|
||||
}
|
||||
|
||||
function getRoleClass(role: string): string {
|
||||
if (role === 'system' || role === 'user' || role === 'assistant') {
|
||||
return `ui-conversation__turn--${role}`;
|
||||
}
|
||||
return 'ui-conversation__turn--unknown';
|
||||
}
|
||||
|
||||
export function ConversationTimeline(props: ConversationTimelineProps) {
|
||||
const parsedRequest = createMemo(() => normalizePayload(props.requestBody));
|
||||
const parsedResponse = createMemo(() => normalizePayload(props.responseBody));
|
||||
|
||||
const requestMessages = createMemo(() => normalizeMessages(parsedRequest()));
|
||||
const responseMessages = createMemo(() => normalizeAssistantMessages(parsedResponse()));
|
||||
const messages = createMemo(() => [...requestMessages(), ...responseMessages()]);
|
||||
|
||||
const summaryItems = createMemo(() => {
|
||||
const request = parsedRequest();
|
||||
const response = parsedResponse();
|
||||
const usage = response?.usage && typeof response.usage === 'object' ? response.usage as Record<string, unknown> : null;
|
||||
|
||||
return [
|
||||
typeof request?.model === 'string' ? { key: 'Model', value: request.model } : null,
|
||||
request?.temperature !== undefined ? { key: 'Temp', value: String(request.temperature) } : null,
|
||||
typeof response?.created === 'number' ? { key: 'Created', value: String(response.created) } : null,
|
||||
usage?.prompt_tokens !== undefined ? { key: 'Prompt', value: String(usage.prompt_tokens) } : null,
|
||||
usage?.completion_tokens !== undefined ? { key: 'Completion', value: String(usage.completion_tokens) } : null,
|
||||
usage?.total_tokens !== undefined ? { key: 'Total', value: String(usage.total_tokens) } : null,
|
||||
].filter((item): item is { key: string; value: string } => Boolean(item));
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="ui-conversation">
|
||||
<Show when={summaryItems().length > 0}>
|
||||
<MetaCluster items={summaryItems()} />
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={messages().length > 0}
|
||||
fallback={<div class="ui-conversation__empty">{props.emptyMessage ?? 'No parsed conversation available for this log.'}</div>}
|
||||
>
|
||||
<div class="ui-conversation__list">
|
||||
<For each={messages()}>
|
||||
{(message, index) => (
|
||||
<article class={`ui-conversation__turn ${getRoleClass(message.role)}`}>
|
||||
<header class="ui-conversation__turn-header">
|
||||
<StatusBadge tone={getRoleTone(message.role)}>{message.role}</StatusBadge>
|
||||
<span class="ui-conversation__turn-index">Turn {index() + 1}</span>
|
||||
</header>
|
||||
<div class="ui-conversation__bubble">
|
||||
<pre class="ui-conversation__content">{message.content}</pre>
|
||||
<Show when={message.metadata && message.metadata.length > 0}>
|
||||
<div class="ui-conversation__meta">
|
||||
<MetaCluster items={message.metadata!} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -379,6 +379,83 @@
|
|||
opacity: 1;
|
||||
}
|
||||
|
||||
.ui-conversation {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-conversation__empty {
|
||||
padding: var(--space-5);
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-bg-panel);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-conversation__list {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.ui-conversation__turn {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ui-conversation__turn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ui-conversation__turn-index {
|
||||
color: var(--color-text-soft);
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
|
||||
.ui-conversation__bubble {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--color-bg-panel);
|
||||
}
|
||||
|
||||
.ui-conversation__turn--system .ui-conversation__bubble {
|
||||
background: var(--color-info-soft);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.ui-conversation__turn--user .ui-conversation__bubble {
|
||||
background: var(--color-warning-soft);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.ui-conversation__turn--assistant .ui-conversation__bubble {
|
||||
background: var(--color-success-soft);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.ui-conversation__turn--unknown .ui-conversation__bubble {
|
||||
background: var(--color-bg-inset);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.ui-conversation__content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-conversation__meta {
|
||||
margin-top: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.ui-data-grid__table {
|
||||
min-width: 640px;
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@
|
|||
}
|
||||
|
||||
.ui-textarea {
|
||||
min-height: 88px;
|
||||
min-height: 400px;
|
||||
padding-top: var(--space-3);
|
||||
padding-bottom: var(--space-3);
|
||||
resize: vertical;
|
||||
|
|
|
|||
|
|
@ -45,6 +45,51 @@ function normalizeRequestLog(row: any): RequestLog {
|
|||
return row as RequestLog;
|
||||
}
|
||||
|
||||
function buildWhereClause(query: RequestLogQuery): { whereClause: string; params: unknown[] } {
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (query.date) {
|
||||
clauses.push('local_date = ?');
|
||||
params.push(query.date);
|
||||
}
|
||||
if (query.userId) {
|
||||
clauses.push('user_id = ?');
|
||||
params.push(query.userId);
|
||||
}
|
||||
if (query.backendId) {
|
||||
clauses.push('backend_id = ?');
|
||||
params.push(query.backendId);
|
||||
}
|
||||
if (query.endpoint) {
|
||||
clauses.push('endpoint = ?');
|
||||
params.push(query.endpoint);
|
||||
}
|
||||
if (query.detailLogged !== undefined) {
|
||||
clauses.push('detail_logged = ?');
|
||||
params.push(query.detailLogged ? 1 : 0);
|
||||
}
|
||||
if (query.q) {
|
||||
const like = `%${query.q}%`;
|
||||
clauses.push(`(
|
||||
endpoint LIKE ?
|
||||
OR COALESCE(request_model, '') LIKE ?
|
||||
OR COALESCE(response_model, '') LIKE ?
|
||||
OR COALESCE(error_message, '') LIKE ?
|
||||
OR COALESCE(request_headers, '') LIKE ?
|
||||
OR COALESCE(request_body, '') LIKE ?
|
||||
OR COALESCE(response_headers, '') LIKE ?
|
||||
OR COALESCE(response_body, '') LIKE ?
|
||||
)`);
|
||||
params.push(like, like, like, like, like, like, like, like);
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function getQueryMonth(query: RequestLogQuery): string {
|
||||
if (query.date) {
|
||||
return getMonthKeyFromDateString(query.date);
|
||||
|
|
@ -111,54 +156,55 @@ export class RequestLogService {
|
|||
}
|
||||
|
||||
static getRequestLogs(query: RequestLogQuery = {}): RequestLog[] {
|
||||
const db = getRequestLogsDb(getQueryMonth(query));
|
||||
const clauses: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (query.date) {
|
||||
clauses.push('local_date = ?');
|
||||
params.push(query.date);
|
||||
}
|
||||
if (query.userId) {
|
||||
clauses.push('user_id = ?');
|
||||
params.push(query.userId);
|
||||
}
|
||||
if (query.backendId) {
|
||||
clauses.push('backend_id = ?');
|
||||
params.push(query.backendId);
|
||||
}
|
||||
if (query.endpoint) {
|
||||
clauses.push('endpoint = ?');
|
||||
params.push(query.endpoint);
|
||||
}
|
||||
if (query.detailLogged !== undefined) {
|
||||
clauses.push('detail_logged = ?');
|
||||
params.push(query.detailLogged ? 1 : 0);
|
||||
}
|
||||
if (query.q) {
|
||||
const like = `%${query.q}%`;
|
||||
clauses.push(`(
|
||||
endpoint LIKE ?
|
||||
OR COALESCE(request_model, '') LIKE ?
|
||||
OR COALESCE(response_model, '') LIKE ?
|
||||
OR COALESCE(error_message, '') LIKE ?
|
||||
OR COALESCE(request_headers, '') LIKE ?
|
||||
OR COALESCE(request_body, '') LIKE ?
|
||||
OR COALESCE(response_headers, '') LIKE ?
|
||||
OR COALESCE(response_body, '') LIKE ?
|
||||
)`);
|
||||
params.push(like, like, like, like, like, like, like, like);
|
||||
}
|
||||
|
||||
const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
|
||||
const limit = clampLimit(query.limit);
|
||||
const offset = Math.max(0, query.offset || 0);
|
||||
let offset = Math.max(0, query.offset || 0);
|
||||
const { whereClause, params } = buildWhereClause(query);
|
||||
|
||||
return db.prepare(`
|
||||
SELECT * FROM request_logs
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset).map(normalizeRequestLog);
|
||||
if (query.month || query.date) {
|
||||
const db = getRequestLogsDb(getQueryMonth(query));
|
||||
return db.prepare(`
|
||||
SELECT * FROM request_logs
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset).map(normalizeRequestLog);
|
||||
}
|
||||
|
||||
const months = listRequestLogMonths();
|
||||
const results: RequestLog[] = [];
|
||||
|
||||
for (const month of months) {
|
||||
if (results.length >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
const db = getRequestLogsDb(month);
|
||||
const remaining = limit - results.length;
|
||||
const rows = db.prepare(`
|
||||
SELECT * FROM request_logs
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, remaining, offset).map(normalizeRequestLog);
|
||||
|
||||
if (rows.length > 0) {
|
||||
results.push(...rows);
|
||||
offset = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchedInMonth = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM request_logs
|
||||
${whereClause}
|
||||
`).get(...params) as { count: number };
|
||||
|
||||
if (matchedInMonth.count <= offset) {
|
||||
offset -= matchedInMonth.count;
|
||||
} else {
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll } from 'vitest';
|
|||
import request from 'supertest';
|
||||
import { createTestApp } from '../utils/testApp';
|
||||
import { initDb } from '../../src/config/database';
|
||||
import { RequestLogService } from '../../src/services/RequestLogService';
|
||||
|
||||
describe('Auth & Proxy API', () => {
|
||||
let app: ReturnType<typeof createTestApp>;
|
||||
|
|
@ -117,5 +118,39 @@ describe('Auth & Proxy API', () => {
|
|||
|
||||
expect(loggedRequest).toBeDefined();
|
||||
});
|
||||
|
||||
it('should paginate across months when month/date are not specified', async () => {
|
||||
RequestLogService.logRequest({
|
||||
user_id: 9991,
|
||||
backend_id: 9991,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: 'cross-month-test-model',
|
||||
status_code: 200,
|
||||
detail_logged: false,
|
||||
error_message: 'cross-month-test-marker',
|
||||
local_date: '2026-02-20',
|
||||
created_at: '2026-02-20T10:00:00.000Z',
|
||||
});
|
||||
|
||||
RequestLogService.logRequest({
|
||||
user_id: 9992,
|
||||
backend_id: 9992,
|
||||
endpoint: '/v1/chat/completions',
|
||||
request_model: 'cross-month-test-model',
|
||||
status_code: 200,
|
||||
detail_logged: false,
|
||||
error_message: 'cross-month-test-marker',
|
||||
local_date: '2026-03-20',
|
||||
created_at: '2026-03-20T10:00:00.000Z',
|
||||
});
|
||||
|
||||
const firstPage = await request(app).get('/admin/analytics/requests?limit=1&offset=0&q=cross-month-test-marker');
|
||||
const secondPage = await request(app).get('/admin/analytics/requests?limit=1&offset=1&q=cross-month-test-marker');
|
||||
|
||||
expect(firstPage.status).toBe(200);
|
||||
expect(secondPage.status).toBe(200);
|
||||
expect(firstPage.body[0].user_id).toBe(9992);
|
||||
expect(secondPage.body[0].user_id).toBe(9991);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -289,6 +289,44 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
expect(response.body).toHaveProperty('choices');
|
||||
expect(response.body.usage).toHaveProperty('total_tokens');
|
||||
});
|
||||
|
||||
it('should replace router Authorization with backend API key for upstream requests', async () => {
|
||||
let receivedAuthorization: string | undefined;
|
||||
const { server, port } = createMockBackend({
|
||||
onRequest: (req) => {
|
||||
receivedAuthorization = req.headers.authorization;
|
||||
},
|
||||
});
|
||||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'Auth Rewrite User 6-6' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
name: 'Auth Rewrite Backend 6-6',
|
||||
base_url: `http://localhost:${mockPort}`,
|
||||
api_key: 'upstream-secret-key',
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${userApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(receivedAuthorization).toBe('Bearer upstream-secret-key');
|
||||
expect(receivedAuthorization).not.toBe(`Bearer ${userApiKey}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenario 7: Models endpoint routing', () => {
|
||||
|
|
@ -350,5 +388,41 @@ describe('OpenAI Compatible Backend Integration', () => {
|
|||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toBe('No backends available for your account');
|
||||
});
|
||||
|
||||
it('should not forward router Authorization when backend API key is absent', async () => {
|
||||
let receivedAuthorization: string | undefined;
|
||||
const { server, port } = createMockBackend({
|
||||
onRequest: (req) => {
|
||||
receivedAuthorization = req.headers.authorization;
|
||||
},
|
||||
});
|
||||
mockServer = server;
|
||||
mockPort = port;
|
||||
|
||||
const userResponse = await request(app).post('/admin/users').send({ name: 'No Upstream Auth User 7-7' });
|
||||
const userApiKey = userResponse.body.api_key;
|
||||
const userId = userResponse.body.id;
|
||||
|
||||
const backendResponse = await request(app).post('/admin/backends').send({
|
||||
name: 'No Upstream Auth Backend 7-7',
|
||||
base_url: `http://localhost:${port}`,
|
||||
});
|
||||
const backendId = backendResponse.body.id;
|
||||
|
||||
await request(app)
|
||||
.post('/admin/permissions')
|
||||
.send({ user_id: userId, backend_id: backendId });
|
||||
|
||||
const response = await request(app)
|
||||
.post('/v1/chat/completions')
|
||||
.set('Authorization', `Bearer ${userApiKey}`)
|
||||
.send({
|
||||
model: 'mock-model',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(receivedAuthorization).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import express from 'express';
|
|||
|
||||
export interface MockBackendOptions {
|
||||
port?: number;
|
||||
onRequest?: (req: express.Request) => void;
|
||||
chatResponse?: Partial<{
|
||||
id: string;
|
||||
model: string;
|
||||
|
|
@ -18,6 +19,7 @@ export interface MockBackendOptions {
|
|||
export function createMockBackend(options: MockBackendOptions = {}) {
|
||||
const {
|
||||
port = 0,
|
||||
onRequest,
|
||||
chatResponse = {
|
||||
id: 'mock-1',
|
||||
model: 'mock-model',
|
||||
|
|
@ -31,10 +33,12 @@ export function createMockBackend(options: MockBackendOptions = {}) {
|
|||
app.use(express.json());
|
||||
|
||||
app.post('/v1/chat/completions', (req, res) => {
|
||||
onRequest?.(req);
|
||||
res.json(chatResponse);
|
||||
});
|
||||
|
||||
app.get('/v1/models', (req, res) => {
|
||||
onRequest?.(req);
|
||||
res.json({ data: modelsResponse });
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue