feat(dashboard): add React foundation and overview
This commit is contained in:
1 parent
59c3a61a88
commit
51e3e28a9b
20 files changed
+3347
-244
No files matched your search
@@ -0,0 +1,60 @@
|
||||
import type { ZodType } from 'zod';
|
||||
import {
|
||||
botStatsSchema,
|
||||
controlResponseSchema,
|
||||
timersSchema,
|
||||
type BotStats,
|
||||
type ControlResponse,
|
||||
type Timers,
|
||||
} from './contracts.js';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
interface ErrorPayload {
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
|
||||
const payload: unknown = await response.json();
|
||||
if (!response.ok) {
|
||||
const errorPayload = payload as ErrorPayload;
|
||||
const message = typeof errorPayload.error === 'string' ? errorPayload.error : `Request failed (${response.status})`;
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
return schema.parse(payload);
|
||||
}
|
||||
|
||||
export interface DashboardApi {
|
||||
getStats(): Promise<BotStats>;
|
||||
getTimers(): Promise<Timers>;
|
||||
setRunning(isRunning: boolean): Promise<ControlResponse>;
|
||||
}
|
||||
|
||||
export function createDashboardApi(fetchImplementation?: typeof fetch): DashboardApi {
|
||||
function request(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
return (fetchImplementation ?? globalThis.fetch)(input, init);
|
||||
}
|
||||
|
||||
return {
|
||||
async getStats() {
|
||||
return parseResponse(await request('/api/stats'), botStatsSchema);
|
||||
},
|
||||
async getTimers() {
|
||||
return parseResponse(await request('/api/timers'), timersSchema);
|
||||
},
|
||||
async setRunning(isRunning) {
|
||||
const endpoint = isRunning ? '/api/start' : '/api/stop';
|
||||
return parseResponse(await request(endpoint, { method: 'POST' }), controlResponseSchema);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const dashboardApi = createDashboardApi();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const activityItemSchema = z.object({
|
||||
timestamp: z.number(),
|
||||
type: z.enum(['sent', 'skipped', 'response', 'error', 'info', 'debug']),
|
||||
message: z.string(),
|
||||
recipient: z.string().optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
});
|
||||
|
||||
export const botStatsSchema = z.object({
|
||||
isRunning: z.boolean(),
|
||||
dryRunMode: z.boolean(),
|
||||
totalProcessed: z.number(),
|
||||
totalMessagesSent: z.number(),
|
||||
totalResponses: z.number(),
|
||||
responseRate: z.number(),
|
||||
messagesThisHour: z.number(),
|
||||
maxMessagesPerHour: z.number(),
|
||||
subreddits: z.array(z.object({ name: z.string(), type: z.enum(['image', 'chat']) })),
|
||||
targetGenders: z.array(z.string()),
|
||||
recentActivity: z.array(activityItemSchema),
|
||||
invitationCooldown: z
|
||||
.object({
|
||||
until: z.number(),
|
||||
reason: z.string(),
|
||||
redditCode: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export const timersSchema = z.object({
|
||||
nextMessageAvailableAt: z.number().nullable(),
|
||||
hourlyResetAt: z.number(),
|
||||
isHourlyLimitReached: z.boolean(),
|
||||
messagesRemaining: z.number(),
|
||||
});
|
||||
|
||||
export const controlResponseSchema = z.object({
|
||||
success: z.literal(true),
|
||||
running: z.boolean(),
|
||||
});
|
||||
|
||||
export type BotStats = z.infer<typeof botStatsSchema>;
|
||||
export type Timers = z.infer<typeof timersSchema>;
|
||||
export type ControlResponse = z.infer<typeof controlResponseSchema>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { dashboardApi } from './client.js';
|
||||
import type { BotStats } from './contracts.js';
|
||||
|
||||
export const dashboardQueryKeys = {
|
||||
stats: ['dashboard', 'stats'] as const,
|
||||
timers: ['dashboard', 'timers'] as const,
|
||||
};
|
||||
|
||||
export function useStatsQuery() {
|
||||
return useQuery({ queryKey: dashboardQueryKeys.stats, queryFn: dashboardApi.getStats });
|
||||
}
|
||||
|
||||
export function useTimersQuery() {
|
||||
return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers });
|
||||
}
|
||||
|
||||
export function useSetRunningMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: dashboardApi.setRunning,
|
||||
onSuccess(response) {
|
||||
queryClient.setQueryData<BotStats>(dashboardQueryKeys.stats, (current) =>
|
||||
current ? { ...current, isRunning: response.running } : current,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user