BREAKCHANGE(database): detail logging add
This commit is contained in:
parent
aca450d3e6
commit
67532df356
37 changed files with 673 additions and 191 deletions
|
|
@ -22,7 +22,7 @@ scripts/ 개발 스크립트
|
|||
- Server: `server/src/index.ts`
|
||||
- Client: `client/src/index.tsx`
|
||||
- Shared types: `shared/types.ts`
|
||||
- DB schema: `database/schema.sql`, `database/analytics-schema.sql`
|
||||
- DB schema: `database/schema.sql`, `database/analytics-schema.sql`, `database/request-logs-schema.sql`
|
||||
|
||||
## Key Concepts
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ Client → Auth → Script(onRequest) → RouterService → Backend → Script(o
|
|||
|
||||
**Script Engine**: isolated-vm 샌드박스에서 JS 실행. 3가지 타입: per-user, per-backend, per-user-backend
|
||||
|
||||
**Database**: core.db (users, backends, permissions, user_scripts) + analytics.db (request_logs, usage_stats, backend_metrics)
|
||||
**Database**: `DB_DIR` 하위에 `core.db` (users, backends, permissions, user_scripts), `analytics.db` (usage_stats, backend_metrics), `request_logs/request_logs_YYYY-MM.db` (상세 요청 로그)
|
||||
|
||||
## Development
|
||||
|
||||
|
|
@ -53,8 +53,8 @@ pnpm run bench # 벤치마크 실행
|
|||
|----------|---------|-------------|
|
||||
| `SERVER_PORT` | 3000 | Express 서버 포트 |
|
||||
| `CLIENT_PORT` | 3001 | Vite 개발 서버 포트 |
|
||||
| `CORE_DB_PATH` | ./data/core.db | Core DB 경로 |
|
||||
| `ANALYTICS_DB_PATH` | ./data/analytics.db | Analytics DB 경로 |
|
||||
| `DB_DIR` | ./data | DB 루트 경로 (`core.db`, `analytics.db`, `request_logs/` 생성 위치) |
|
||||
| `TZ` | UTC | 일/월 경계 계산용 타임존. 저장 timestamp는 UTC |
|
||||
| `ADMIN_PASSWORD` | (필수) | 어드민 비밀번호 |
|
||||
| `CORS_ORIGINS` | localhost origins | 허용 CORS 오리진 |
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const api = {
|
|||
users: {
|
||||
getAll: (): Promise<User[]> => fetchJson<User[]>(`${API_BASE}/admin/users`),
|
||||
getById: (id: number): Promise<User> => fetchJson<User>(`${API_BASE}/admin/users/${id}`),
|
||||
create: (data: { name: string; email?: string }): Promise<User> =>
|
||||
create: (data: { name: string; email?: string; detail_logging?: boolean }): Promise<User> =>
|
||||
fetchJson<User>(`${API_BASE}/admin/users`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<User>): Promise<User> =>
|
||||
fetchJson<User>(`${API_BASE}/admin/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
|
@ -40,7 +40,7 @@ export const api = {
|
|||
backends: {
|
||||
getAll: (): Promise<Backend[]> => fetchJson<Backend[]>(`${API_BASE}/admin/backends`),
|
||||
getById: (id: number): Promise<Backend> => fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`),
|
||||
create: (data: { name: string; base_url: string; api_key?: string }): Promise<Backend> =>
|
||||
create: (data: { name: string; base_url: string; api_key?: string; detail_logging?: boolean }): Promise<Backend> =>
|
||||
fetchJson<Backend>(`${API_BASE}/admin/backends`, { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<Backend>): Promise<Backend> =>
|
||||
fetchJson<Backend>(`${API_BASE}/admin/backends/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
|
|
@ -85,8 +85,19 @@ export const api = {
|
|||
params.append('days', String(days));
|
||||
return fetchJson<UsageStats[]>(`${API_BASE}/admin/analytics/usage?${params}`);
|
||||
},
|
||||
getRequests: (limit: number = 100, offset: number = 0): Promise<RequestLog[]> =>
|
||||
fetchJson<RequestLog[]>(`${API_BASE}/admin/analytics/requests?limit=${limit}&offset=${offset}`),
|
||||
getRequests: (params: { limit?: number; offset?: number; month?: string; date?: string; q?: string; userId?: number; backendId?: number; endpoint?: string; detailLogged?: boolean } = {}): Promise<RequestLog[]> => {
|
||||
const search = new URLSearchParams();
|
||||
search.set('limit', String(params.limit ?? 100));
|
||||
search.set('offset', String(params.offset ?? 0));
|
||||
if (params.month) search.set('month', params.month);
|
||||
if (params.date) search.set('date', params.date);
|
||||
if (params.q) search.set('q', params.q);
|
||||
if (params.userId) search.set('userId', String(params.userId));
|
||||
if (params.backendId) search.set('backendId', String(params.backendId));
|
||||
if (params.endpoint) search.set('endpoint', params.endpoint);
|
||||
if (params.detailLogged !== undefined) search.set('detailLogged', params.detailLogged ? '1' : '0');
|
||||
return fetchJson<RequestLog[]>(`${API_BASE}/admin/analytics/requests?${search}`);
|
||||
},
|
||||
getMetrics: (backendId?: number, days: number = 30): Promise<BackendMetrics[]> => {
|
||||
const params = new URLSearchParams();
|
||||
if (backendId) params.append('backendId', String(backendId));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Layout } from '../components/Layout';
|
|||
import { DataGrid, EmptyState, MetaCluster, PageHeader, Panel, StatusBadge, SummaryStrip } from '../ui';
|
||||
|
||||
export const Analytics: Component = () => {
|
||||
const [requests] = createResource(() => api.analytics.getRequests(50));
|
||||
const [requests] = createResource(() => api.analytics.getRequests({ limit: 50 }));
|
||||
const [usage] = createResource(() => api.analytics.getUsage(undefined, undefined, 7));
|
||||
const [metrics] = createResource(() => api.analytics.getMetrics(undefined, 7));
|
||||
|
||||
|
|
@ -38,7 +38,12 @@ export const Analytics: Component = () => {
|
|||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
cell: (row) => <StatusBadge tone={row.status_code >= 400 ? 'danger' : 'success'}>{String(row.status_code)}</StatusBadge>,
|
||||
cell: (row) => <StatusBadge tone={row.status_code >= 400 ? 'danger' : 'success'}>{String(row.status_code)}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'detail',
|
||||
header: 'Detail',
|
||||
cell: (row) => <StatusBadge tone={row.detail_logged ? 'warning' : 'neutral'}>{row.detail_logged ? 'Verbose' : 'Meta'}</StatusBadge>,
|
||||
},
|
||||
]}
|
||||
getRowKey={(row) => row.id}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface BackendFormState {
|
|||
base_url: string;
|
||||
api_key: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (): BackendFormState => ({
|
||||
|
|
@ -30,6 +31,7 @@ const emptyForm = (): BackendFormState => ({
|
|||
base_url: '',
|
||||
api_key: '',
|
||||
is_active: true,
|
||||
detail_logging: false,
|
||||
});
|
||||
|
||||
export const Backends: Component = () => {
|
||||
|
|
@ -55,6 +57,7 @@ export const Backends: Component = () => {
|
|||
base_url: backend.base_url,
|
||||
api_key: backend.api_key ?? '',
|
||||
is_active: backend.is_active,
|
||||
detail_logging: backend.detail_logging,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
|
@ -76,6 +79,7 @@ export const Backends: Component = () => {
|
|||
base_url: current.base_url.trim(),
|
||||
api_key: current.api_key.trim() || undefined,
|
||||
is_active: current.is_active,
|
||||
detail_logging: current.detail_logging,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'Backend updated.' });
|
||||
} else {
|
||||
|
|
@ -83,6 +87,7 @@ export const Backends: Component = () => {
|
|||
name: current.name.trim(),
|
||||
base_url: current.base_url.trim(),
|
||||
api_key: current.api_key.trim() || undefined,
|
||||
detail_logging: current.detail_logging,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'Backend created.' });
|
||||
}
|
||||
|
|
@ -159,6 +164,11 @@ export const Backends: Component = () => {
|
|||
class: 'ui-text-mono',
|
||||
cell: (backend) => <span title={backend.base_url}>{backend.base_url}</span>,
|
||||
},
|
||||
{
|
||||
id: 'detail_logging',
|
||||
header: 'Detail Log',
|
||||
cell: (backend) => <StatusBadge tone={backend.detail_logging ? 'warning' : 'neutral'}>{backend.detail_logging ? 'On' : 'Off'}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
|
|
@ -214,6 +224,12 @@ export const Backends: Component = () => {
|
|||
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
|
||||
/>
|
||||
</Show>
|
||||
<Checkbox
|
||||
label="Enable detailed logging"
|
||||
description="When enabled, proxied request and response headers/bodies are stored for this backend."
|
||||
checked={form().detail_logging}
|
||||
onChange={(checked) => setForm((current) => ({ ...current, detail_logging: checked }))}
|
||||
/>
|
||||
</form>
|
||||
</FormDialog>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const Dashboard: Component = () => {
|
|||
const [data, { refetch }] = createResource(async () => ({
|
||||
users: await api.users.getAll(),
|
||||
backends: await api.backends.getAll(),
|
||||
recentRequests: await api.analytics.getRequests(10),
|
||||
recentRequests: await api.analytics.getRequests({ limit: 10 }),
|
||||
}));
|
||||
|
||||
return (
|
||||
|
|
@ -39,6 +39,11 @@ export const Dashboard: Component = () => {
|
|||
header: 'Status',
|
||||
cell: (request) => <StatusBadge tone={request.status_code >= 400 ? 'danger' : 'success'}>{String(request.status_code)}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'detail_logged',
|
||||
header: 'Detail',
|
||||
cell: (request) => <StatusBadge tone={request.detail_logged ? 'warning' : 'neutral'}>{request.detail_logged ? 'Verbose' : 'Meta'}</StatusBadge>,
|
||||
},
|
||||
{ id: 'time', header: 'Time', cell: (request) => <span>{new Date(request.created_at).toLocaleString()}</span> },
|
||||
]}
|
||||
getRowKey={(request) => request.id}
|
||||
|
|
|
|||
|
|
@ -28,12 +28,14 @@ interface UserFormState {
|
|||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = (): UserFormState => ({
|
||||
name: '',
|
||||
email: '',
|
||||
is_active: true,
|
||||
detail_logging: false,
|
||||
});
|
||||
|
||||
const maskApiKey = (apiKey: string) => `${apiKey.slice(0, 5)}...`;
|
||||
|
|
@ -73,6 +75,7 @@ export const Users: Component = () => {
|
|||
name: user.name,
|
||||
email: user.email ?? '',
|
||||
is_active: user.is_active,
|
||||
detail_logging: user.detail_logging,
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
|
@ -93,12 +96,14 @@ export const Users: Component = () => {
|
|||
name: current.name.trim(),
|
||||
email: current.email.trim() || undefined,
|
||||
is_active: current.is_active,
|
||||
detail_logging: current.detail_logging,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'User updated.' });
|
||||
} else {
|
||||
await api.users.create({
|
||||
name: current.name.trim(),
|
||||
email: current.email.trim() || undefined,
|
||||
detail_logging: current.detail_logging,
|
||||
});
|
||||
setNotice({ tone: 'success', message: 'User created.' });
|
||||
}
|
||||
|
|
@ -237,6 +242,11 @@ export const Users: Component = () => {
|
|||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'detail_logging',
|
||||
header: 'Detail Log',
|
||||
cell: (user) => <StatusBadge tone={user.detail_logging ? 'warning' : 'neutral'}>{user.detail_logging ? 'On' : 'Off'}</StatusBadge>,
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Status',
|
||||
|
|
@ -309,6 +319,12 @@ export const Users: Component = () => {
|
|||
onChange={(checked) => setForm((current) => ({ ...current, is_active: checked }))}
|
||||
/>
|
||||
</Show>
|
||||
<Checkbox
|
||||
label="Enable detailed logging"
|
||||
description="When enabled, proxied request and response headers/bodies are stored for this user."
|
||||
checked={form().detail_logging}
|
||||
onChange={(checked) => setForm((current) => ({ ...current, detail_logging: checked }))}
|
||||
/>
|
||||
</form>
|
||||
</FormDialog>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type User = {
|
|||
name: string;
|
||||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
|
@ -14,6 +15,7 @@ export type Backend = {
|
|||
base_url: string;
|
||||
api_key?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
|
@ -38,6 +40,12 @@ export type RequestLog = {
|
|||
status_code: number;
|
||||
response_time_ms?: number;
|
||||
error_message?: string;
|
||||
detail_logged: boolean;
|
||||
local_date: string;
|
||||
request_headers?: string;
|
||||
request_body?: string;
|
||||
response_headers?: string;
|
||||
response_body?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,6 @@
|
|||
-- Analytics Database Schema
|
||||
-- Stores request logs, usage stats, and backend metrics
|
||||
|
||||
-- Request logs table
|
||||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
backend_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
response_model TEXT,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
status_code INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Usage stats table (aggregated daily per user-backend pair)
|
||||
CREATE TABLE IF NOT EXISTS usage_stats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
|
@ -43,10 +26,6 @@ CREATE TABLE IF NOT EXISTS backend_metrics (
|
|||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_user ON request_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_backend ON request_logs(backend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_date ON request_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_user_backend ON request_logs(user_id, backend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_user ON usage_stats(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_stats_date ON usage_stats(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_backend_metrics_backend ON backend_metrics(backend_id);
|
||||
|
|
|
|||
29
database/request-logs-schema.sql
Normal file
29
database/request-logs-schema.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
backend_id INTEGER NOT NULL,
|
||||
endpoint TEXT NOT NULL,
|
||||
request_model TEXT,
|
||||
response_model TEXT,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
status_code INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
error_message TEXT,
|
||||
detail_logged INTEGER NOT NULL DEFAULT 0,
|
||||
local_date TEXT NOT NULL,
|
||||
request_headers TEXT,
|
||||
request_body TEXT,
|
||||
response_headers TEXT,
|
||||
response_body TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_local_date ON request_logs(local_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_user ON request_logs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_backend ON request_logs(backend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_endpoint ON request_logs(endpoint);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_detail_logged ON request_logs(detail_logged);
|
||||
|
||||
|
|
@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
detail_logging INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
@ -19,6 +20,7 @@ CREATE TABLE IF NOT EXISTS backends (
|
|||
base_url TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
detail_logging INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
| GET | `/admin/users` | 전체 사용자 목록 |
|
||||
| POST | `/admin/users` | 사용자 생성 (API 키 자동 발급) |
|
||||
| GET | `/admin/users/:id` | 사용자 조회 |
|
||||
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, is_active) |
|
||||
| PUT | `/admin/users/:id` | 사용자 수정 (name, email, is_active, detail_logging) |
|
||||
| DELETE | `/admin/users/:id` | 사용자 삭제 |
|
||||
| POST | `/admin/users/:id/regenerate-api-key` | API 키 재발급 |
|
||||
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/admin/backends` | 전체 백엔드 목록 |
|
||||
| POST | `/admin/backends` | 백엔드 생성 (name, base_url, api_key) |
|
||||
| POST | `/admin/backends` | 백엔드 생성 (name, base_url, api_key, detail_logging) |
|
||||
| GET | `/admin/backends/:id` | 백엔드 조회 |
|
||||
| PUT | `/admin/backends/:id` | 백엔드 수정 |
|
||||
| DELETE | `/admin/backends/:id` | 백엔드 삭제 |
|
||||
|
|
@ -72,5 +72,7 @@
|
|||
| Method | Path | Query Params | Description |
|
||||
|--------|------|-------------|-------------|
|
||||
| GET | `/admin/analytics/usage` | userId, backendId, days | 사용량 통계 |
|
||||
| GET | `/admin/analytics/requests` | limit, offset | 요청 로그 (페이지네이션) |
|
||||
| GET | `/admin/analytics/requests` | month, date, limit, offset, q, userId, backendId, endpoint, detailLogged | 월별 상세 요청 로그 조회 |
|
||||
| GET | `/admin/analytics/metrics` | backendId, days | 백엔드 성능 메트릭 |
|
||||
|
||||
상세 로그는 `users.detail_logging=1` 또는 `backends.detail_logging=1`일 때만 request/response header/body가 저장된다.
|
||||
|
|
|
|||
|
|
@ -14,10 +14,10 @@ client/src/
|
|||
index.ts # TypeScript 타입 정의
|
||||
routes/
|
||||
Dashboard.tsx # 홈 — 요약 카드 (사용자 수, 활성 백엔드, 최근 요청) + 최근 요청 테이블
|
||||
Users.tsx # 사용자 관리 — CRUD, API 키 발급/재발급, 활성화 토글
|
||||
Backends.tsx # 백엔드 관리 — CRUD (name, base_url, api_key)
|
||||
Users.tsx # 사용자 관리 — CRUD, API 키 발급/재발급, 활성화/상세 로깅 토글
|
||||
Backends.tsx # 백엔드 관리 — CRUD (name, base_url, api_key, detail_logging)
|
||||
Permissions.tsx # 권한 관리 — user-backend 매핑
|
||||
Analytics.tsx # 분석 — 요청 로그, 사용량 통계, 백엔드 메트릭
|
||||
Analytics.tsx # 분석 — 월별 요청 로그, 사용량 통계, 백엔드 메트릭
|
||||
Scripts.tsx # 스크립트 관리 — CRUD, 테스트, 활성화/비활성화
|
||||
components/
|
||||
Layout.tsx # 사이드바 네비게이션 + 메인 콘텐츠 레이아웃
|
||||
|
|
@ -54,3 +54,8 @@ CSS 프레임워크 없음. 인라인 `style` props 사용.
|
|||
## Dev Server
|
||||
|
||||
포트: 3002 (vite.config.ts), API 프록시: `/api` → `http://localhost:3000`
|
||||
|
||||
## Analytics Notes
|
||||
|
||||
- 요청 로그 조회는 월별 request_logs 파일을 대상으로 한다.
|
||||
- analytics API 클라이언트는 `month`, `date`, `q`, `limit`, `offset` 등 필터를 지원한다.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Database Schema
|
||||
|
||||
두 개의 SQLite 데이터베이스를 사용한다. 설정 데이터는 `core.db`, 운영 데이터는 `analytics.db`에 저장된다.
|
||||
DB는 `DB_DIR` 하위에 분리 저장된다.
|
||||
|
||||
스키마 원본: [database/schema.sql](../database/schema.sql), [database/analytics-schema.sql](../database/analytics-schema.sql)
|
||||
스키마 원본: [database/schema.sql](../database/schema.sql), [database/analytics-schema.sql](../database/analytics-schema.sql), [database/request-logs-schema.sql](../database/request-logs-schema.sql)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
| name | TEXT | NOT NULL |
|
||||
| email | TEXT | |
|
||||
| is_active | BOOLEAN | DEFAULT 1 |
|
||||
| detail_logging | INTEGER | DEFAULT 0 |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ Indexes: `idx_users_api_key(api_key)`
|
|||
| base_url | TEXT | NOT NULL |
|
||||
| api_key | TEXT | |
|
||||
| is_active | BOOLEAN | DEFAULT 1 |
|
||||
| detail_logging | INTEGER | DEFAULT 0 |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
|
|
@ -68,29 +70,11 @@ Indexes: `idx_user_scripts_type`, `idx_user_scripts_active`, `idx_user_scripts_t
|
|||
|
||||
## Analytics Database (analytics.db)
|
||||
|
||||
### request_logs
|
||||
### usage_stats
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||
| user_id | INTEGER | NOT NULL |
|
||||
| backend_id | INTEGER | NOT NULL |
|
||||
| endpoint | TEXT | NOT NULL |
|
||||
| request_model | TEXT | |
|
||||
| response_model | TEXT | |
|
||||
| prompt_tokens | INTEGER | |
|
||||
| completion_tokens | INTEGER | |
|
||||
| total_tokens | INTEGER | |
|
||||
| status_code | INTEGER | |
|
||||
| response_time_ms | INTEGER | |
|
||||
| error_message | TEXT | |
|
||||
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP |
|
||||
|
||||
Indexes: `idx_request_logs_user`, `idx_request_logs_backend`, `idx_request_logs_date(created_at)`, `idx_request_logs_user_backend`
|
||||
|
||||
### usage_stats
|
||||
|
||||
일별 집계 테이블.
|
||||
일별 집계 테이블. 날짜 경계는 `TZ`, 저장 timestamp는 UTC 기준.
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
|
|
@ -121,3 +105,33 @@ Indexes: `idx_usage_stats_user`, `idx_usage_stats_date`
|
|||
|
||||
Unique: `(backend_id, date)`
|
||||
Indexes: `idx_backend_metrics_backend`, `idx_backend_metrics_date`
|
||||
|
||||
## Monthly Request Logs (`request_logs/request_logs_YYYY-MM.db`)
|
||||
|
||||
월별 상세 요청 로그는 별도 SQLite 파일에 저장된다.
|
||||
|
||||
### request_logs
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
| id | INTEGER | PRIMARY KEY AUTOINCREMENT |
|
||||
| user_id | INTEGER | NOT NULL |
|
||||
| backend_id | INTEGER | NOT NULL |
|
||||
| endpoint | TEXT | NOT NULL |
|
||||
| request_model | TEXT | |
|
||||
| response_model | TEXT | |
|
||||
| prompt_tokens | INTEGER | |
|
||||
| completion_tokens | INTEGER | |
|
||||
| total_tokens | INTEGER | |
|
||||
| status_code | INTEGER | |
|
||||
| response_time_ms | INTEGER | |
|
||||
| error_message | TEXT | |
|
||||
| detail_logged | INTEGER | DEFAULT 0 |
|
||||
| local_date | TEXT | `TZ` 기준 `YYYY-MM-DD` |
|
||||
| request_headers | TEXT | JSON 문자열 |
|
||||
| request_body | TEXT | JSON 또는 raw 문자열 |
|
||||
| response_headers | TEXT | JSON 문자열 |
|
||||
| response_body | TEXT | JSON 또는 raw 문자열 |
|
||||
| created_at | TEXT | UTC ISO timestamp |
|
||||
|
||||
Indexes: `created_at`, `local_date`, `user_id`, `backend_id`, `endpoint`, `detail_logged`
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ interface ScriptContextData {
|
|||
}
|
||||
```
|
||||
|
||||
참고:
|
||||
- `request.body`, `response.body`는 직렬화 가능한 값이며 보통 JSON 객체로 전달된다.
|
||||
- 스크립트가 body를 수정하면 라우터가 업스트림 전송 전에 최종 body를 기준으로 직렬화하고 `content-length`를 다시 계산한다.
|
||||
|
||||
## Example
|
||||
|
||||
```javascript
|
||||
|
|
|
|||
|
|
@ -8,8 +8,10 @@
|
|||
server/src/
|
||||
index.ts # Express 앱 팩토리 (CORS, 라우트 설정, health 엔드포인트)
|
||||
config/
|
||||
db-paths.ts # DB_DIR 기준 파일 경로 계산
|
||||
database.ts # Core SQLite 연결 및 스키마 초기화
|
||||
analytics-db.ts # Analytics SQLite 연결 및 스키마 초기화
|
||||
request-logs-db.ts # 월별 request_logs SQLite 연결 및 스키마 초기화
|
||||
models/
|
||||
User.ts # 사용자 CRUD (create, findById, findByApiKey, update, delete, regenerateApiKey)
|
||||
Backend.ts # 백엔드 CRUD (create, findById, findAll, activate/deactivate)
|
||||
|
|
@ -23,13 +25,15 @@ server/src/
|
|||
scripts.ts # Script 관리 엔드포인트
|
||||
analytics.ts # Analytics 조회 엔드포인트
|
||||
services/
|
||||
RouterService.ts # 백엔드 선택 (랜덤 로드밸런싱) 및 HTTP 요청 포워딩
|
||||
AnalyticsService.ts # 요청 로깅, 일별 사용량/메트릭 집계
|
||||
RouterService.ts # 백엔드 선택 (랜덤 로드밸런싱), HTTP 요청 포워딩, body/content-length 정규화
|
||||
AnalyticsService.ts # 일별 사용량/메트릭 집계
|
||||
RequestLogService.ts # 월별 request_logs 기록/조회
|
||||
ScriptEngine.ts # 스크립트 체인 오케스트레이션 (onRequest/onResponse 훅 적용)
|
||||
ScriptExecutor.ts # isolated-vm 기반 스크립트 컴파일/실행 (5s timeout, 50MB memory)
|
||||
utils/
|
||||
apiKey.ts # API 키 생성 (sk-{timestamp}-{random}, crypto.randomBytes)
|
||||
logger.ts # 컬러 콘솔 로거
|
||||
time.ts # TZ 기준 날짜/월 계산, UTC timestamp 생성
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
|
@ -40,10 +44,16 @@ Client → auth.ts (API 키 검증, 권한 로드)
|
|||
→ ScriptEngine.applyOnRequestScripts (요청 변조)
|
||||
→ RouterService.forwardRequest (백엔드 프록시)
|
||||
→ ScriptEngine.applyOnResponseScripts (응답 변조)
|
||||
→ AnalyticsService.logRequest (로깅)
|
||||
→ AnalyticsService.logRequest (집계 + 월별 request_logs 기록)
|
||||
→ Response
|
||||
```
|
||||
|
||||
## Time & Storage
|
||||
|
||||
- DB 루트는 `DB_DIR`로 설정한다. 파일은 `core.db`, `analytics.db`, `request_logs/request_logs_YYYY-MM.db` 구조로 생성된다.
|
||||
- 일/월 경계 계산은 `TZ` 기준으로 수행한다.
|
||||
- 저장되는 timestamp 문자열은 UTC ISO 형식으로 통일한다.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const ANALYTICS_DB_PATH = process.env.ANALYTICS_DB_PATH || path.join(process.cwd(), 'data', 'analytics.db');
|
||||
import { ensureDir, getAnalyticsDbPath } from './db-paths';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
export function getAnalyticsDb(): Database.Database {
|
||||
if (!db) {
|
||||
const dataDir = path.dirname(ANALYTICS_DB_PATH);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const analyticsDbPath = getAnalyticsDbPath();
|
||||
ensureDir(path.dirname(analyticsDbPath));
|
||||
|
||||
db = new Database(ANALYTICS_DB_PATH);
|
||||
db = new Database(analyticsDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
|
||||
|
|
@ -29,12 +26,10 @@ export function initAnalyticsDb(): Database.Database {
|
|||
db.close();
|
||||
}
|
||||
|
||||
const dataDir = path.dirname(ANALYTICS_DB_PATH);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const analyticsDbPath = getAnalyticsDbPath();
|
||||
ensureDir(path.dirname(analyticsDbPath));
|
||||
|
||||
db = new Database(ANALYTICS_DB_PATH);
|
||||
db = new Database(analyticsDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'analytics-schema.sql');
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const CORE_DB_PATH = process.env.CORE_DB_PATH || path.join(process.cwd(), 'data', 'core.db');
|
||||
import { ensureDir, getCoreDbPath } from './db-paths';
|
||||
|
||||
let db: Database.Database;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
const dataDir = path.dirname(CORE_DB_PATH);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const coreDbPath = getCoreDbPath();
|
||||
ensureDir(path.dirname(coreDbPath));
|
||||
|
||||
db = new Database(CORE_DB_PATH);
|
||||
db = new Database(coreDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'schema.sql');
|
||||
|
|
@ -29,12 +26,10 @@ export function initDb(): Database.Database {
|
|||
db.close();
|
||||
}
|
||||
|
||||
const dataDir = path.dirname(CORE_DB_PATH);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
const coreDbPath = getCoreDbPath();
|
||||
ensureDir(path.dirname(coreDbPath));
|
||||
|
||||
db = new Database(CORE_DB_PATH);
|
||||
db = new Database(coreDbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'schema.sql');
|
||||
|
|
|
|||
31
server/src/config/db-paths.ts
Normal file
31
server/src/config/db-paths.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DEFAULT_DB_DIR = path.join(process.cwd(), 'data');
|
||||
|
||||
export function getDbRootDir(): string {
|
||||
return process.env.DB_DIR || process.env.DB_PATH || DEFAULT_DB_DIR;
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function getCoreDbPath(): string {
|
||||
return path.join(getDbRootDir(), 'core.db');
|
||||
}
|
||||
|
||||
export function getAnalyticsDbPath(): string {
|
||||
return path.join(getDbRootDir(), 'analytics.db');
|
||||
}
|
||||
|
||||
export function getRequestLogsDir(): string {
|
||||
return path.join(getDbRootDir(), 'request_logs');
|
||||
}
|
||||
|
||||
export function getRequestLogsDbPath(monthKey: string): string {
|
||||
return path.join(getRequestLogsDir(), `request_logs_${monthKey}.db`);
|
||||
}
|
||||
|
||||
56
server/src/config/request-logs-db.ts
Normal file
56
server/src/config/request-logs-db.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { ensureDir, getRequestLogsDbPath, getRequestLogsDir } from './db-paths';
|
||||
import { getLocalMonthKey } from '../utils/time';
|
||||
|
||||
const connections = new Map<string, Database.Database>();
|
||||
|
||||
function initRequestLogsSchema(db: Database.Database): void {
|
||||
const schemaPath = path.join(__dirname, '..', '..', '..', 'database', 'request-logs-schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
}
|
||||
|
||||
export function getRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
|
||||
const existing = connections.get(monthKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const dbPath = getRequestLogsDbPath(monthKey);
|
||||
ensureDir(path.dirname(dbPath));
|
||||
|
||||
const db = new Database(dbPath);
|
||||
initRequestLogsSchema(db);
|
||||
connections.set(monthKey, db);
|
||||
return db;
|
||||
}
|
||||
|
||||
export function initRequestLogsDb(monthKey: string = getLocalMonthKey()): Database.Database {
|
||||
const existing = connections.get(monthKey);
|
||||
if (existing) {
|
||||
existing.close();
|
||||
connections.delete(monthKey);
|
||||
}
|
||||
|
||||
return getRequestLogsDb(monthKey);
|
||||
}
|
||||
|
||||
export function listRequestLogMonths(): string[] {
|
||||
const requestLogsDir = getRequestLogsDir();
|
||||
ensureDir(requestLogsDir);
|
||||
|
||||
return fs
|
||||
.readdirSync(requestLogsDir)
|
||||
.map((entry) => /^request_logs_(\d{4}-\d{2})\.db$/.exec(entry)?.[1])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.sort((a, b) => b.localeCompare(a));
|
||||
}
|
||||
|
||||
export function closeRequestLogsDbs(): void {
|
||||
for (const db of connections.values()) {
|
||||
db.close();
|
||||
}
|
||||
connections.clear();
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import adminRoutes from './routes/admin';
|
|||
import apiRoutes from './routes/api';
|
||||
import analyticsRoutes from './routes/analytics';
|
||||
import { logger } from './utils/logger';
|
||||
import { getUtcTimestamp } from './utils/time';
|
||||
|
||||
dotenv.config({
|
||||
quiet: true,
|
||||
|
|
@ -30,7 +31,7 @@ export function createServer(): Application {
|
|||
app.use('/admin/analytics', analyticsRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { Backend, CreateBackendData, UpdateBackendData } from '../../../shared/types';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
export class BackendModel {
|
||||
static asBackend(row: any): Backend {
|
||||
row.is_active = !!row.is_active;
|
||||
row.detail_logging = !!row.detail_logging;
|
||||
return row;
|
||||
}
|
||||
|
||||
|
|
@ -25,10 +27,12 @@ export class BackendModel {
|
|||
}
|
||||
|
||||
static create(data: CreateBackendData): Backend {
|
||||
const timestamp = getUtcTimestamp();
|
||||
const detailLogging = data.detail_logging ?? false;
|
||||
const stmt = getDb().prepare(
|
||||
'INSERT INTO backends (name, base_url, api_key) VALUES (?, ?, ?)'
|
||||
'INSERT INTO backends (name, base_url, api_key, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(data.name, data.base_url, data.api_key || null);
|
||||
const result = stmt.run(data.name, data.base_url, data.api_key || null, detailLogging ? 1 : 0, timestamp, timestamp);
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
|
|
@ -36,8 +40,9 @@ export class BackendModel {
|
|||
base_url: data.base_url,
|
||||
api_key: data.api_key,
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
detail_logging: detailLogging,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -61,12 +66,17 @@ export class BackendModel {
|
|||
updates.push('is_active = ?');
|
||||
values.push(data.is_active ? 1 : 0);
|
||||
}
|
||||
if (data.detail_logging !== undefined) {
|
||||
updates.push('detail_logging = ?');
|
||||
values.push(data.detail_logging ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
updates.push('updated_at = ?');
|
||||
values.push(getUtcTimestamp());
|
||||
values.push(id);
|
||||
|
||||
getDb().prepare(`UPDATE backends SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
|
@ -79,7 +89,7 @@ export class BackendModel {
|
|||
}
|
||||
|
||||
static deactivate(id: number): boolean {
|
||||
const result = getDb().prepare('UPDATE backends SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
const result = getDb().prepare('UPDATE backends SET is_active = 0, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { Permission, CreatePermissionData } from '../../../shared/types';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
export class PermissionModel {
|
||||
static findAll(): Permission[] {
|
||||
|
|
@ -20,16 +21,17 @@ export class PermissionModel {
|
|||
|
||||
static create(data: CreatePermissionData): Permission {
|
||||
try {
|
||||
const timestamp = getUtcTimestamp();
|
||||
const stmt = getDb().prepare(
|
||||
'INSERT INTO permissions (user_id, backend_id) VALUES (?, ?)'
|
||||
'INSERT INTO permissions (user_id, backend_id, created_at) VALUES (?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(data.user_id, data.backend_id);
|
||||
const result = stmt.run(data.user_id, data.backend_id, timestamp);
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
user_id: data.user_id,
|
||||
backend_id: data.backend_id,
|
||||
created_at: new Date().toISOString(),
|
||||
created_at: timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { UserScript, CreateScriptData, UpdateScriptData } from '../../../shared/types';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
export class ScriptModel {
|
||||
static asUserScript(row: any): UserScript {
|
||||
|
|
@ -34,8 +35,9 @@ export class ScriptModel {
|
|||
|
||||
static create(data: CreateScriptData): UserScript {
|
||||
try {
|
||||
const timestamp = getUtcTimestamp();
|
||||
const stmt = getDb().prepare(
|
||||
'INSERT INTO user_scripts (name, script_type, target_user_id, target_backend_id, script_code, is_active) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
'INSERT INTO user_scripts (name, script_type, target_user_id, target_backend_id, script_code, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const isActive = data.is_active ?? true;
|
||||
const result = stmt.run(
|
||||
|
|
@ -44,7 +46,9 @@ export class ScriptModel {
|
|||
data.target_user_id ?? null,
|
||||
data.target_backend_id ?? null,
|
||||
data.script_code,
|
||||
isActive ? 1 : 0
|
||||
isActive ? 1 : 0,
|
||||
timestamp,
|
||||
timestamp
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
@ -55,8 +59,8 @@ export class ScriptModel {
|
|||
target_backend_id: data.target_backend_id ?? null,
|
||||
script_code: data.script_code,
|
||||
is_active: isActive,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('UNIQUE constraint failed')) {
|
||||
|
|
@ -99,7 +103,8 @@ export class ScriptModel {
|
|||
return this.findById(id);
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
updates.push('updated_at = ?');
|
||||
values.push(getUtcTimestamp());
|
||||
values.push(id);
|
||||
|
||||
getDb().prepare(`UPDATE user_scripts SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
|
@ -112,12 +117,12 @@ export class ScriptModel {
|
|||
}
|
||||
|
||||
static activate(id: number): boolean {
|
||||
const result = getDb().prepare('UPDATE user_scripts SET is_active = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
const result = getDb().prepare('UPDATE user_scripts SET is_active = 1, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
static deactivate(id: number): boolean {
|
||||
const result = getDb().prepare('UPDATE user_scripts SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
const result = getDb().prepare('UPDATE user_scripts SET is_active = 0, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { getDb } from '../config/database';
|
||||
import { User, CreateUserData, UpdateUserData } from '../../../shared/types';
|
||||
import { generateApiKey } from '../utils/apiKey';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
export class UserModel {
|
||||
static asUser(row: any): User {
|
||||
row.is_active = !!row.is_active;
|
||||
row.detail_logging = !!row.detail_logging;
|
||||
return row as User;
|
||||
}
|
||||
|
||||
|
|
@ -27,10 +29,12 @@ export class UserModel {
|
|||
|
||||
static create(data: CreateUserData): User {
|
||||
const apiKey = `sk-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||
const timestamp = getUtcTimestamp();
|
||||
const detailLogging = data.detail_logging ?? false;
|
||||
const stmt = getDb().prepare(
|
||||
'INSERT INTO users (api_key, name, email) VALUES (?, ?, ?)'
|
||||
'INSERT INTO users (api_key, name, email, detail_logging, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const result = stmt.run(apiKey, data.name, data.email || null);
|
||||
const result = stmt.run(apiKey, data.name, data.email || null, detailLogging ? 1 : 0, timestamp, timestamp);
|
||||
|
||||
return {
|
||||
id: result.lastInsertRowid as number,
|
||||
|
|
@ -38,8 +42,9 @@ export class UserModel {
|
|||
name: data.name,
|
||||
email: data.email,
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
detail_logging: detailLogging,
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,12 +64,17 @@ export class UserModel {
|
|||
updates.push('is_active = ?');
|
||||
values.push(data.is_active ? 1 : 0);
|
||||
}
|
||||
if (data.detail_logging !== undefined) {
|
||||
updates.push('detail_logging = ?');
|
||||
values.push(data.detail_logging ? 1 : 0);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||
updates.push('updated_at = ?');
|
||||
values.push(getUtcTimestamp());
|
||||
values.push(id);
|
||||
|
||||
getDb().prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
||||
|
|
@ -77,7 +87,7 @@ export class UserModel {
|
|||
}
|
||||
|
||||
static deactivate(id: number): boolean {
|
||||
const result = getDb().prepare('UPDATE users SET is_active = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
const result = getDb().prepare('UPDATE users SET is_active = 0, updated_at = ? WHERE id = ?').run(getUtcTimestamp(), id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +96,7 @@ export class UserModel {
|
|||
if (!user) return null;
|
||||
|
||||
const newApiKey = generateApiKey();
|
||||
getDb().prepare('UPDATE users SET api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(newApiKey, id);
|
||||
getDb().prepare('UPDATE users SET api_key = ?, updated_at = ? WHERE id = ?').run(newApiKey, getUtcTimestamp(), id);
|
||||
return newApiKey;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { UserModel } from '../models/User';
|
|||
import { BackendModel } from '../models/Backend';
|
||||
import { PermissionModel } from '../models/Permission';
|
||||
import scriptRoutes from './scripts';
|
||||
import { generateApiKey } from '../utils/apiKey';
|
||||
import { CreateUserData, CreateBackendData, CreatePermissionData, UpdateUserData, UpdateBackendData } from '../../../shared/types';
|
||||
import { getUtcTimestamp } from '../utils/time';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ router.get('/users', (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
router.post('/users', (req: Request, res: Response) => {
|
||||
const { name, email } = req.body as CreateUserData;
|
||||
const { name, email, detail_logging } = req.body as CreateUserData;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
|
|
@ -28,6 +28,7 @@ router.post('/users', (req: Request, res: Response) => {
|
|||
const user = UserModel.create({
|
||||
name,
|
||||
email,
|
||||
detail_logging,
|
||||
});
|
||||
|
||||
const updatedUser = UserModel.regenerateApiKey(user.id);
|
||||
|
|
@ -59,8 +60,8 @@ router.put('/users/:id', (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { name, email, is_active } = req.body as UpdateUserData;
|
||||
const updatedUser = UserModel.update(id, { name, email, is_active });
|
||||
const { name, email, is_active, detail_logging } = req.body as UpdateUserData;
|
||||
const updatedUser = UserModel.update(id, { name, email, is_active, detail_logging });
|
||||
|
||||
res.json(updatedUser);
|
||||
});
|
||||
|
|
@ -103,14 +104,14 @@ router.get('/backends', (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
router.post('/backends', (req: Request, res: Response) => {
|
||||
const { name, base_url, api_key } = req.body as CreateBackendData;
|
||||
const { name, base_url, api_key, detail_logging } = req.body as CreateBackendData;
|
||||
|
||||
if (!name || !base_url) {
|
||||
res.status(400).json({ error: 'Name and base_url are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const backend = BackendModel.create({ name, base_url, api_key });
|
||||
const backend = BackendModel.create({ name, base_url, api_key, detail_logging });
|
||||
res.status(201).json(backend);
|
||||
});
|
||||
|
||||
|
|
@ -135,8 +136,8 @@ router.put('/backends/:id', (req: Request, res: Response) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const { name, base_url, api_key, is_active } = req.body as UpdateBackendData;
|
||||
const updatedBackend = BackendModel.update(id, { name, base_url, api_key, is_active });
|
||||
const { name, base_url, api_key, is_active, detail_logging } = req.body as UpdateBackendData;
|
||||
const updatedBackend = BackendModel.update(id, { name, base_url, api_key, is_active, detail_logging });
|
||||
|
||||
res.json(updatedBackend);
|
||||
});
|
||||
|
|
@ -213,7 +214,7 @@ router.delete('/permissions', (req: Request, res: Response) => {
|
|||
// ============ Health Check ============
|
||||
|
||||
router.get('/health', (req: Request, res: Response) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -14,11 +14,18 @@ router.get('/usage', (req: Request, res: Response) => {
|
|||
});
|
||||
|
||||
router.get('/requests', (req: Request, res: Response) => {
|
||||
const { limit, offset } = req.query;
|
||||
const result = AnalyticsService.getRequestLogs(
|
||||
limit ? Number(limit) : 100,
|
||||
offset ? Number(offset) : 0
|
||||
);
|
||||
const { month, date, limit, offset, q, userId, backendId, endpoint, detailLogged } = req.query;
|
||||
const result = AnalyticsService.getRequestLogs({
|
||||
month: typeof month === 'string' ? month : undefined,
|
||||
date: typeof date === 'string' ? date : undefined,
|
||||
limit: limit ? Number(limit) : 100,
|
||||
offset: offset ? Number(offset) : 0,
|
||||
q: typeof q === 'string' ? q : undefined,
|
||||
userId: userId ? Number(userId) : undefined,
|
||||
backendId: backendId ? Number(backendId) : undefined,
|
||||
endpoint: typeof endpoint === 'string' ? endpoint : undefined,
|
||||
detailLogged: detailLogged === undefined ? undefined : detailLogged === '1' || detailLogged === 'true',
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ const router: Router = Router();
|
|||
|
||||
router.use(authenticate);
|
||||
|
||||
function normalizeHeaders(headers: Request['headers']): Record<string, string> {
|
||||
return Object.entries(headers).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
acc[key] = value.join(', ');
|
||||
} else if (typeof value === 'string') {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
const user = req.user!;
|
||||
|
|
@ -27,14 +38,18 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
|
||||
try {
|
||||
const { model, messages, ...rest } = req.body;
|
||||
const detailLoggingEnabled = user.detail_logging || backend.detail_logging;
|
||||
|
||||
const execContext = {
|
||||
user: { id: user.id, name: user.name, email: user.email },
|
||||
backend: { id: backend.id, name: backend.name, base_url: backend.base_url },
|
||||
request: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
path: req.path,
|
||||
headers: {
|
||||
...normalizeHeaders(req.headers),
|
||||
'content-type': req.get('content-type') || 'application/json',
|
||||
},
|
||||
body: req.body,
|
||||
isStream: req.body.stream === true,
|
||||
},
|
||||
|
|
@ -62,7 +77,7 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
|
||||
const responseContext = {
|
||||
status: response.status,
|
||||
headers: {},
|
||||
headers: response.headers,
|
||||
body: response.data,
|
||||
isStream: req.body.stream === true,
|
||||
};
|
||||
|
|
@ -86,6 +101,12 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
status_code: response.status,
|
||||
response_time_ms: responseTime,
|
||||
error_message: response.status >= 400 ? JSON.stringify(response.data) : undefined,
|
||||
detail_logged: detailLoggingEnabled,
|
||||
request_headers: detailLoggingEnabled ? modifiedContext.request.headers : undefined,
|
||||
request_body: detailLoggingEnabled ? modifiedContext.request.body : undefined,
|
||||
response_headers: detailLoggingEnabled ? response.headers : undefined,
|
||||
response_body: detailLoggingEnabled ? response.data : undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
|
||||
if (response.status >= 400) {
|
||||
|
|
@ -110,6 +131,12 @@ router.post('/chat/completions', async (req: AuthenticatedRequest, res: Response
|
|||
status_code: 502,
|
||||
response_time_ms: responseTime,
|
||||
error_message: errorMsg,
|
||||
detail_logged: user.detail_logging || backend.detail_logging,
|
||||
request_headers: user.detail_logging || backend.detail_logging ? normalizeHeaders(req.headers) : undefined,
|
||||
request_body: user.detail_logging || backend.detail_logging ? req.body : undefined,
|
||||
response_headers: undefined,
|
||||
response_body: undefined,
|
||||
local_date: undefined,
|
||||
});
|
||||
|
||||
logger.error(`Request failed for user ${user.id}: ${errorMsg}`);
|
||||
|
|
|
|||
|
|
@ -1,31 +1,14 @@
|
|||
import { getAnalyticsDb } from '../config/analytics-db';
|
||||
import { RequestLog } from '../../../shared/types';
|
||||
import { RequestLogInsert, RequestLogQuery, RequestLogService } from './RequestLogService';
|
||||
import { getLocalDateKey } from '../utils/time';
|
||||
|
||||
type AnalyticsLogInput = RequestLogInsert;
|
||||
|
||||
export class AnalyticsService {
|
||||
static logRequest(logData: Omit<RequestLog, 'id' | 'created_at'>): void {
|
||||
static logRequest(logData: AnalyticsLogInput): void {
|
||||
try {
|
||||
const db = getAnalyticsDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO request_logs (
|
||||
user_id, backend_id, endpoint, request_model, response_model,
|
||||
prompt_tokens, completion_tokens, total_tokens,
|
||||
status_code, response_time_ms, error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
logData.user_id,
|
||||
logData.backend_id,
|
||||
logData.endpoint,
|
||||
logData.request_model || null,
|
||||
logData.response_model || null,
|
||||
logData.prompt_tokens || null,
|
||||
logData.completion_tokens || null,
|
||||
logData.total_tokens || null,
|
||||
logData.status_code,
|
||||
logData.response_time_ms || null,
|
||||
logData.error_message || null
|
||||
);
|
||||
RequestLogService.logRequest(logData);
|
||||
|
||||
this.updateUsageStats(logData.user_id, logData.backend_id, logData.total_tokens || 0);
|
||||
this.updateBackendMetrics(logData.backend_id, logData);
|
||||
|
|
@ -36,7 +19,7 @@ export class AnalyticsService {
|
|||
|
||||
private static updateUsageStats(userId: number, backendId: number, tokens: number): void {
|
||||
const db = getAnalyticsDb();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = getLocalDateKey();
|
||||
|
||||
const upsertStmt = db.prepare(`
|
||||
INSERT INTO usage_stats (user_id, backend_id, date, total_requests, total_tokens)
|
||||
|
|
@ -50,9 +33,9 @@ export class AnalyticsService {
|
|||
upsertStmt.run(userId, backendId, today, tokens, tokens);
|
||||
}
|
||||
|
||||
private static updateBackendMetrics(backendId: number, logData: Omit<RequestLog, 'id' | 'created_at'>): void {
|
||||
private static updateBackendMetrics(backendId: number, logData: AnalyticsLogInput): void {
|
||||
const db = getAnalyticsDb();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const today = getLocalDateKey();
|
||||
const isSuccess = logData.status_code >= 200 && logData.status_code < 300;
|
||||
|
||||
const existing = db.prepare(
|
||||
|
|
@ -99,17 +82,14 @@ export class AnalyticsService {
|
|||
}
|
||||
}
|
||||
|
||||
static getRequestLogs(limit: number = 100, offset: number = 0): RequestLog[] {
|
||||
const db = getAnalyticsDb();
|
||||
return db.prepare(`
|
||||
SELECT * FROM request_logs ORDER BY created_at DESC LIMIT ? OFFSET ?
|
||||
`).all(limit, offset) as RequestLog[];
|
||||
static getRequestLogs(query: RequestLogQuery = {}): RequestLog[] {
|
||||
return RequestLogService.getRequestLogs(query);
|
||||
}
|
||||
|
||||
static getUsageStats(userId?: number, backendId?: number, days: number = 30): unknown[] {
|
||||
const db = getAnalyticsDb();
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const endDate = getLocalDateKey();
|
||||
const startDate = getLocalDateKey(new Date(Date.now() - days * 24 * 60 * 60 * 1000));
|
||||
|
||||
let query = `
|
||||
SELECT * FROM usage_stats
|
||||
|
|
@ -133,8 +113,8 @@ export class AnalyticsService {
|
|||
|
||||
static getBackendMetrics(backendId?: number, days: number = 30): unknown[] {
|
||||
const db = getAnalyticsDb();
|
||||
const endDate = new Date().toISOString().split('T')[0];
|
||||
const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const endDate = getLocalDateKey();
|
||||
const startDate = getLocalDateKey(new Date(Date.now() - days * 24 * 60 * 60 * 1000));
|
||||
|
||||
let query = `
|
||||
SELECT * FROM backend_metrics
|
||||
|
|
|
|||
164
server/src/services/RequestLogService.ts
Normal file
164
server/src/services/RequestLogService.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { listRequestLogMonths, getRequestLogsDb } from '../config/request-logs-db';
|
||||
import { RequestLog } from '../../../shared/types';
|
||||
import { getLocalDateKey, getLocalMonthKey, getMonthKeyFromDateString, getUtcTimestamp } from '../utils/time';
|
||||
|
||||
export interface RequestLogInsert {
|
||||
user_id: number;
|
||||
backend_id: number;
|
||||
endpoint: string;
|
||||
request_model?: string;
|
||||
response_model?: string;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
status_code: number;
|
||||
response_time_ms?: number;
|
||||
error_message?: string;
|
||||
detail_logged?: boolean;
|
||||
local_date?: string;
|
||||
request_headers?: unknown;
|
||||
request_body?: unknown;
|
||||
response_headers?: unknown;
|
||||
response_body?: unknown;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface RequestLogQuery {
|
||||
month?: string;
|
||||
date?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
q?: string;
|
||||
userId?: number;
|
||||
backendId?: number;
|
||||
endpoint?: string;
|
||||
detailLogged?: boolean;
|
||||
}
|
||||
|
||||
function clampLimit(limit: number | undefined): number {
|
||||
if (!limit || Number.isNaN(limit)) return 100;
|
||||
return Math.max(1, Math.min(limit, 100));
|
||||
}
|
||||
|
||||
function normalizeRequestLog(row: any): RequestLog {
|
||||
row.detail_logged = !!row.detail_logged;
|
||||
return row as RequestLog;
|
||||
}
|
||||
|
||||
function getQueryMonth(query: RequestLogQuery): string {
|
||||
if (query.date) {
|
||||
return getMonthKeyFromDateString(query.date);
|
||||
}
|
||||
if (query.month) {
|
||||
return query.month;
|
||||
}
|
||||
|
||||
const months = listRequestLogMonths();
|
||||
return months[0] || getLocalMonthKey();
|
||||
}
|
||||
|
||||
function stringifySnapshot(value: unknown): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestLogService {
|
||||
static logRequest(logData: RequestLogInsert): void {
|
||||
const createdAt = logData.created_at || getUtcTimestamp();
|
||||
const localDate = logData.local_date || getLocalDateKey();
|
||||
const monthKey = getMonthKeyFromDateString(localDate);
|
||||
const detailLogged = logData.detail_logged ?? false;
|
||||
const db = getRequestLogsDb(monthKey);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO request_logs (
|
||||
user_id, backend_id, endpoint, request_model, response_model,
|
||||
prompt_tokens, completion_tokens, total_tokens,
|
||||
status_code, response_time_ms, error_message, detail_logged,
|
||||
local_date, request_headers, request_body, response_headers, response_body, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
logData.user_id,
|
||||
logData.backend_id,
|
||||
logData.endpoint,
|
||||
logData.request_model || null,
|
||||
logData.response_model || null,
|
||||
logData.prompt_tokens || null,
|
||||
logData.completion_tokens || null,
|
||||
logData.total_tokens || null,
|
||||
logData.status_code,
|
||||
logData.response_time_ms || null,
|
||||
logData.error_message || null,
|
||||
detailLogged ? 1 : 0,
|
||||
localDate,
|
||||
stringifySnapshot(logData.request_headers),
|
||||
stringifySnapshot(logData.request_body),
|
||||
stringifySnapshot(logData.response_headers),
|
||||
stringifySnapshot(logData.response_body),
|
||||
createdAt
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return db.prepare(`
|
||||
SELECT * FROM request_logs
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`).all(...params, limit, offset).map(normalizeRequestLog);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,22 @@ import { Backend } from '../../../shared/types';
|
|||
import { BackendModel } from '../models/Backend';
|
||||
|
||||
export class RouterService {
|
||||
private static prepareRequestBody(body?: unknown): string | Uint8Array | ArrayBuffer | undefined {
|
||||
if (body === undefined || body === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof body === 'string') {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
static selectBackend(allowedBackendIds: number[]): Backend | null {
|
||||
if (allowedBackendIds.length === 0) {
|
||||
return null;
|
||||
|
|
@ -25,7 +41,7 @@ export class RouterService {
|
|||
method: string,
|
||||
headers: Record<string, string>,
|
||||
body?: unknown
|
||||
): Promise<{ status: number; data: unknown }> {
|
||||
): Promise<{ status: number; data: unknown; headers: Record<string, string> }> {
|
||||
let backendPath = path;
|
||||
if (backend.base_url.includes('/v1')) {
|
||||
backendPath = path.replace(/^\/v1/, '');
|
||||
|
|
@ -37,6 +53,11 @@ export class RouterService {
|
|||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
};
|
||||
const preparedBody = this.prepareRequestBody(body);
|
||||
|
||||
// Always let fetch/undici compute Content-Length from the final outgoing body.
|
||||
delete fetchHeaders['content-length'];
|
||||
delete fetchHeaders['Content-Length'];
|
||||
|
||||
if (backend.api_key) {
|
||||
fetchHeaders['Authorization'] = `Bearer ${backend.api_key}`;
|
||||
|
|
@ -46,14 +67,16 @@ export class RouterService {
|
|||
const response = await fetch(backendUrl, {
|
||||
method,
|
||||
headers: fetchHeaders,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
body: preparedBody,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const responseHeaders = Object.fromEntries(response.headers.entries());
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
data,
|
||||
headers: responseHeaders,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
|
@ -108,6 +131,7 @@ export class RouterService {
|
|||
return {
|
||||
status: 502,
|
||||
data: detailedError,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { getUtcTimestamp } from './time';
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
|
|
@ -9,7 +11,7 @@ const colors = {
|
|||
};
|
||||
|
||||
export function log(level: 'log' | 'debug' | 'info' | 'warn' | 'error', message: string, meta?: unknown): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const timestamp = getUtcTimestamp();
|
||||
const levelColor = {
|
||||
log: colors.blue,
|
||||
debug: colors.gray,
|
||||
|
|
|
|||
53
server/src/utils/time.ts
Normal file
53
server/src/utils/time.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
const DEFAULT_TIME_ZONE = 'UTC';
|
||||
|
||||
function getFormatter(
|
||||
timeZone: string,
|
||||
options: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function getParts(date: Date, timeZone: string): Record<string, string> {
|
||||
const formatter = getFormatter(timeZone, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
|
||||
return formatter.formatToParts(date).reduce<Record<string, string>>((acc, part) => {
|
||||
if (part.type !== 'literal') {
|
||||
acc[part.type] = part.value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function getConfiguredTimeZone(): string {
|
||||
return process.env.TZ || DEFAULT_TIME_ZONE;
|
||||
}
|
||||
|
||||
export function getUtcTimestamp(date: Date = new Date()): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
export function getLocalDateKey(date: Date = new Date(), timeZone: string = getConfiguredTimeZone()): string {
|
||||
const parts = getParts(date, timeZone);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
export function getLocalMonthKey(date: Date = new Date(), timeZone: string = getConfiguredTimeZone()): string {
|
||||
const parts = getParts(date, timeZone);
|
||||
return `${parts.year}-${parts.month}`;
|
||||
}
|
||||
|
||||
export function getMonthKeyFromDateString(dateString: string): string {
|
||||
return dateString.slice(0, 7);
|
||||
}
|
||||
|
||||
|
|
@ -204,7 +204,7 @@ describe('Admin API - Backend Management', () => {
|
|||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.name).toBe('Updated Backend');
|
||||
expect(response.body.is_active).toBe(0);
|
||||
expect(response.body.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent backend', async () => {
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ export const onResponse = (context) => {
|
|||
.send({ is_active: false });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.is_active).toBe(0);
|
||||
expect(response.body.is_active).toBe(false);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent script', async () => {
|
||||
|
|
|
|||
|
|
@ -1,33 +1,31 @@
|
|||
import { beforeAll, afterAll } from 'vitest';
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Test database paths
|
||||
const TEST_CORE_DB_PATH = path.join(__dirname, '..', 'data', 'test-core.db');
|
||||
const TEST_ANALYTICS_DB_PATH = path.join(__dirname, '..', 'data', 'test-analytics.db');
|
||||
const workerId = process.env.VITEST_POOL_ID || process.env.VITEST_WORKER_ID || String(process.pid);
|
||||
const TEST_DB_DIR = path.join(__dirname, '..', 'data', `test-db-${workerId}`);
|
||||
|
||||
// Set environment variables for test databases
|
||||
process.env.CORE_DB_PATH = TEST_CORE_DB_PATH;
|
||||
process.env.ANALYTICS_DB_PATH = TEST_ANALYTICS_DB_PATH;
|
||||
process.env.DB_DIR = TEST_DB_DIR;
|
||||
process.env.TZ = 'Asia/Seoul';
|
||||
|
||||
// Clear test databases before all tests
|
||||
beforeAll(() => {
|
||||
try {
|
||||
execSync(`rm -f "${TEST_CORE_DB_PATH}" "${TEST_ANALYTICS_DB_PATH}"`, { stdio: 'ignore' });
|
||||
} catch (e) {
|
||||
// Ignore errors if files don't exist
|
||||
if (fs.existsSync(TEST_DB_DIR)) {
|
||||
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(() => {
|
||||
import('../src/config/database').then(({ closeDb }) => closeDb()).catch(() => {});
|
||||
import('../src/config/analytics-db').then(({ closeAnalyticsDb }) => closeAnalyticsDb()).catch(() => {});
|
||||
|
||||
// Remove test databases
|
||||
try {
|
||||
execSync(`rm -f "${TEST_CORE_DB_PATH}" "${TEST_ANALYTICS_DB_PATH}"`, { stdio: 'ignore' });
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
afterAll(async () => {
|
||||
const [{ closeDb }, { closeAnalyticsDb }, { closeRequestLogsDbs }] = await Promise.all([
|
||||
import('../src/config/database'),
|
||||
import('../src/config/analytics-db'),
|
||||
import('../src/config/request-logs-db'),
|
||||
]);
|
||||
|
||||
closeDb();
|
||||
closeAnalyticsDb();
|
||||
closeRequestLogsDbs();
|
||||
|
||||
if (fs.existsSync(TEST_DB_DIR)) {
|
||||
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,11 +5,14 @@ import { initAnalyticsDb } from '../../src/config/analytics-db';
|
|||
import adminRoutes from '../../src/routes/admin';
|
||||
import apiRoutes from '../../src/routes/api';
|
||||
import analyticsRoutes from '../../src/routes/analytics';
|
||||
import { initRequestLogsDb } from '../../src/config/request-logs-db';
|
||||
import { getUtcTimestamp } from '../../src/utils/time';
|
||||
|
||||
export function createTestApp() {
|
||||
// Initialize both databases
|
||||
initDb();
|
||||
initAnalyticsDb();
|
||||
initRequestLogsDb();
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -21,7 +24,7 @@ export function createTestApp() {
|
|||
app.use('/admin/analytics', analyticsRoutes);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
res.json({ status: 'ok', timestamp: getUtcTimestamp() });
|
||||
});
|
||||
|
||||
// Error handling middleware
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export interface User {
|
|||
name: string;
|
||||
email?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -14,6 +15,7 @@ export interface Backend {
|
|||
base_url: string;
|
||||
api_key?: string;
|
||||
is_active: boolean;
|
||||
detail_logging: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -28,12 +30,14 @@ export interface Permission {
|
|||
export interface CreateUserData {
|
||||
name: string;
|
||||
email?: string;
|
||||
detail_logging?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateBackendData {
|
||||
name: string;
|
||||
base_url: string;
|
||||
api_key?: string;
|
||||
detail_logging?: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePermissionData {
|
||||
|
|
@ -45,6 +49,7 @@ export interface UpdateUserData {
|
|||
name?: string;
|
||||
email?: string;
|
||||
is_active?: boolean;
|
||||
detail_logging?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateBackendData {
|
||||
|
|
@ -52,6 +57,7 @@ export interface UpdateBackendData {
|
|||
base_url?: string;
|
||||
api_key?: string;
|
||||
is_active?: boolean;
|
||||
detail_logging?: boolean;
|
||||
}
|
||||
|
||||
export interface RequestLog {
|
||||
|
|
@ -67,6 +73,12 @@ export interface RequestLog {
|
|||
status_code: number;
|
||||
response_time_ms?: number;
|
||||
error_message?: string;
|
||||
detail_logged: boolean;
|
||||
local_date: string;
|
||||
request_headers?: string;
|
||||
request_body?: string;
|
||||
response_headers?: string;
|
||||
response_body?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue