mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add admin system logs UI and API support
Introduce a complete admin System Logs feature: adds frontend components (filters, date picker, active filter chips, rows, detail panel, skeletons, pagination, toolbar, user typeahead, and styles) under src/app/admin/logs/components, plus hooks (useAutoRefreshControl, useLogsUrlState, useUserSearch) and types. Add constants for job labels and log filters, wire URL-driven filters/search/date-range/hasError/user/audiobookQuery with pause-on-interact behavior and page-size options. Update API route (/api/admin/logs) to support the expanded query params and exported where-builder. Update documentation (TABLEOFCONTENTS and admin-dashboard) and add/adjust tests for the new admin logs UI and API behavior.
This commit is contained in:
+133
-13
@@ -10,27 +10,147 @@ import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Logs');
|
||||
|
||||
const VALID_LIMITS = [25, 50, 100] as const;
|
||||
const DEFAULT_LIMIT = 50;
|
||||
const ERROR_STATUSES = ['failed', 'stuck'] as const;
|
||||
|
||||
export interface LogsWhereParams {
|
||||
status?: string | null;
|
||||
type?: string | null;
|
||||
search?: string | null;
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
hasError?: string | null;
|
||||
userId?: string | null;
|
||||
audiobookQuery?: string | null;
|
||||
}
|
||||
|
||||
function parseLimit(raw: string | null): number {
|
||||
const n = Number(raw);
|
||||
return (VALID_LIMITS as readonly number[]).includes(n) ? n : DEFAULT_LIMIT;
|
||||
}
|
||||
|
||||
function parsePage(raw: string | null): number {
|
||||
const n = parseInt(raw ?? '1', 10);
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1;
|
||||
}
|
||||
|
||||
function isTruthy(raw: string | null | undefined): boolean {
|
||||
if (!raw) return false;
|
||||
const v = raw.toLowerCase();
|
||||
return v === 'true' || v === '1';
|
||||
}
|
||||
|
||||
function parseDate(raw: string | null | undefined): Date | null {
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function trim(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
const t = raw.trim();
|
||||
return t.length > 0 ? t : null;
|
||||
}
|
||||
|
||||
export function buildLogsWhere(params: LogsWhereParams): Record<string, any> {
|
||||
const where: Record<string, any> = {};
|
||||
|
||||
const status = params.status ?? 'all';
|
||||
if (status !== 'all' && status !== '') {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
const type = params.type ?? 'all';
|
||||
if (type !== 'all' && type !== '') {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const from = parseDate(params.dateFrom);
|
||||
const to = parseDate(params.dateTo);
|
||||
if (from || to) {
|
||||
where.createdAt = {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lte: to } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const userId = trim(params.userId);
|
||||
if (userId) {
|
||||
where.request = { is: { userId } };
|
||||
}
|
||||
|
||||
const audiobookQuery = trim(params.audiobookQuery);
|
||||
if (audiobookQuery) {
|
||||
where.request = {
|
||||
is: {
|
||||
...(where.request?.is ?? {}),
|
||||
audiobook: {
|
||||
is: {
|
||||
OR: [
|
||||
{ title: { contains: audiobookQuery, mode: 'insensitive' } },
|
||||
{ author: { contains: audiobookQuery, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const errorsOnly = isTruthy(params.hasError);
|
||||
const search = trim(params.search);
|
||||
|
||||
const errorsOr = errorsOnly
|
||||
? [
|
||||
{ status: { in: [...ERROR_STATUSES] } },
|
||||
{ errorMessage: { not: null } },
|
||||
]
|
||||
: null;
|
||||
|
||||
const searchOr = search
|
||||
? [
|
||||
{ bullJobId: { startsWith: search } },
|
||||
{ errorMessage: { contains: search, mode: 'insensitive' } },
|
||||
// TODO: revisit if slow — consider denormalized lastEventMessage on Job
|
||||
{ events: { some: { message: { contains: search, mode: 'insensitive' } } } },
|
||||
{ request: { is: { audiobook: { is: { title: { contains: search, mode: 'insensitive' } } } } } },
|
||||
{ request: { is: { audiobook: { is: { author: { contains: search, mode: 'insensitive' } } } } } },
|
||||
{ request: { is: { user: { is: { plexUsername: { contains: search, mode: 'insensitive' } } } } } },
|
||||
]
|
||||
: null;
|
||||
|
||||
if (errorsOr && searchOr) {
|
||||
where.AND = [{ OR: errorsOr }, { OR: searchOr }];
|
||||
} else if (errorsOr) {
|
||||
where.OR = errorsOr;
|
||||
} else if (searchOr) {
|
||||
where.OR = searchOr;
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const status = searchParams.get('status') || 'all';
|
||||
const type = searchParams.get('type') || 'all';
|
||||
const page = parsePage(searchParams.get('page'));
|
||||
const limit = parseLimit(searchParams.get('limit'));
|
||||
|
||||
const where = buildLogsWhere({
|
||||
status: searchParams.get('status'),
|
||||
type: searchParams.get('type'),
|
||||
search: searchParams.get('search'),
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
dateTo: searchParams.get('dateTo'),
|
||||
hasError: searchParams.get('hasError'),
|
||||
userId: searchParams.get('userId'),
|
||||
audiobookQuery: searchParams.get('audiobookQuery'),
|
||||
});
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Build where clause
|
||||
const where: any = {};
|
||||
if (status !== 'all') {
|
||||
where.status = status;
|
||||
}
|
||||
if (type !== 'all') {
|
||||
where.type = type;
|
||||
}
|
||||
|
||||
const [logs, totalCount] = await Promise.all([
|
||||
prisma.job.findMany({
|
||||
where,
|
||||
|
||||
Reference in New Issue
Block a user