Initial Upload
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
Some checks failed
CI / Lint & Typecheck (push) Has been cancelled
CI / Test (routes) (push) Has been cancelled
CI / Test (security) (push) Has been cancelled
CI / Test (services) (push) Has been cancelled
CI / Test (unit) (push) Has been cancelled
CI / Test (integration) (push) Has been cancelled
CI / Test Coverage (push) Has been cancelled
CI / Build (push) Has been cancelled
This commit is contained in:
11
apps/web/src/hooks/index.ts
Normal file
11
apps/web/src/hooks/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Auth
|
||||
export { AuthProvider, useAuth, useRequireAuth } from './useAuth';
|
||||
|
||||
// Socket
|
||||
export { SocketProvider, useSocket } from './useSocket';
|
||||
|
||||
// Progress estimation
|
||||
export { useEstimatedProgress } from './useEstimatedProgress';
|
||||
|
||||
// React Query hooks
|
||||
export * from './queries';
|
||||
73
apps/web/src/hooks/queries/index.ts
Normal file
73
apps/web/src/hooks/queries/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Stats hooks
|
||||
export {
|
||||
useDashboardStats,
|
||||
usePlaysStats,
|
||||
useUserStats,
|
||||
useLocationStats,
|
||||
usePlaysByDayOfWeek,
|
||||
usePlaysByHourOfDay,
|
||||
usePlatformStats,
|
||||
useQualityStats,
|
||||
useTopUsers,
|
||||
useTopContent,
|
||||
useConcurrentStats,
|
||||
type LocationStatsFilters,
|
||||
type StatsTimeRange,
|
||||
} from './useStats';
|
||||
|
||||
// Session hooks
|
||||
export { useSessions, useActiveSessions, useSession } from './useSessions';
|
||||
export { useTerminateSession } from './useTerminateSession';
|
||||
|
||||
// User hooks
|
||||
export {
|
||||
useUsers,
|
||||
useUser,
|
||||
useUserFull,
|
||||
useUserSessions,
|
||||
useUpdateUser,
|
||||
useUpdateUserIdentity,
|
||||
useUserLocations,
|
||||
useUserDevices,
|
||||
useUserTerminations,
|
||||
} from './useUsers';
|
||||
|
||||
// Rule hooks
|
||||
export {
|
||||
useRules,
|
||||
useCreateRule,
|
||||
useUpdateRule,
|
||||
useDeleteRule,
|
||||
useToggleRule,
|
||||
} from './useRules';
|
||||
|
||||
// Violation hooks
|
||||
export {
|
||||
useViolations,
|
||||
useAcknowledgeViolation,
|
||||
useDismissViolation,
|
||||
} from './useViolations';
|
||||
|
||||
// Server hooks
|
||||
export {
|
||||
useServers,
|
||||
useCreateServer,
|
||||
useDeleteServer,
|
||||
useSyncServer,
|
||||
} from './useServers';
|
||||
|
||||
// Settings hooks
|
||||
export { useSettings, useUpdateSettings } from './useSettings';
|
||||
|
||||
// Channel Routing hooks
|
||||
export { useChannelRouting, useUpdateChannelRouting } from './useChannelRouting';
|
||||
|
||||
// Mobile hooks
|
||||
export {
|
||||
useMobileConfig,
|
||||
useEnableMobile,
|
||||
useDisableMobile,
|
||||
useGeneratePairToken,
|
||||
useRevokeSession,
|
||||
useRevokeMobileSessions,
|
||||
} from './useMobile';
|
||||
61
apps/web/src/hooks/queries/useChannelRouting.ts
Normal file
61
apps/web/src/hooks/queries/useChannelRouting.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NotificationChannelRouting, NotificationEventType } from '@tracearr/shared';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useChannelRouting() {
|
||||
return useQuery({
|
||||
queryKey: ['channelRouting'],
|
||||
queryFn: api.channelRouting.getAll,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateChannelRouting() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
eventType,
|
||||
...data
|
||||
}: {
|
||||
eventType: NotificationEventType;
|
||||
discordEnabled?: boolean;
|
||||
webhookEnabled?: boolean;
|
||||
webToastEnabled?: boolean;
|
||||
}) => api.channelRouting.update(eventType, data),
|
||||
onMutate: async ({ eventType, discordEnabled, webhookEnabled, webToastEnabled }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['channelRouting'] });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousRouting = queryClient.getQueryData<NotificationChannelRouting[]>(['channelRouting']);
|
||||
|
||||
// Optimistically update
|
||||
queryClient.setQueryData<NotificationChannelRouting[]>(['channelRouting'], (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((routing) => {
|
||||
if (routing.eventType !== eventType) return routing;
|
||||
return {
|
||||
...routing,
|
||||
...(discordEnabled !== undefined && { discordEnabled }),
|
||||
...(webhookEnabled !== undefined && { webhookEnabled }),
|
||||
...(webToastEnabled !== undefined && { webToastEnabled }),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return { previousRouting };
|
||||
},
|
||||
onError: (err, _variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousRouting) {
|
||||
queryClient.setQueryData(['channelRouting'], context.previousRouting);
|
||||
}
|
||||
toast.error('Failed to Update Routing', { description: err.message });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['channelRouting'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
93
apps/web/src/hooks/queries/useMobile.ts
Normal file
93
apps/web/src/hooks/queries/useMobile.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { MobileConfig } from '@tracearr/shared';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useMobileConfig() {
|
||||
return useQuery({
|
||||
queryKey: ['mobile', 'config'],
|
||||
queryFn: api.mobile.get,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableMobile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.mobile.enable,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<MobileConfig>(['mobile', 'config'], data);
|
||||
toast.success('Mobile Access Enabled', { description: 'Scan the QR code with the Tracearr mobile app to connect.' });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('Failed to Enable Mobile Access', { description: err.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisableMobile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.mobile.disable,
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData<MobileConfig>(['mobile', 'config'], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, isEnabled: false, token: null, sessions: [] };
|
||||
});
|
||||
toast.success('Mobile Access Disabled', { description: 'All mobile sessions have been revoked.' });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('Failed to Disable Mobile Access', { description: err.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeneratePairToken() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.mobile.generatePairToken,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['mobile', 'config'] });
|
||||
toast.success('Pair Token Generated', { description: 'Scan the QR code with your mobile device to pair.' });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('Failed to Generate Pair Token', { description: err.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => api.mobile.revokeSession(sessionId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['mobile', 'config'] });
|
||||
toast.success('Device Removed', { description: 'The device has been disconnected.' });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('Failed to Remove Device', { description: err.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeMobileSessions() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: api.mobile.revokeSessions,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<MobileConfig>(['mobile', 'config'], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, sessions: [] };
|
||||
});
|
||||
toast.success('Sessions Revoked', { description: `${data.revokedCount} mobile session(s) have been revoked.` });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error('Failed to Revoke Sessions', { description: err.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
94
apps/web/src/hooks/queries/useRules.ts
Normal file
94
apps/web/src/hooks/queries/useRules.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Rule } from '@tracearr/shared';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useRules() {
|
||||
return useQuery({
|
||||
queryKey: ['rules', 'list'],
|
||||
queryFn: api.rules.list,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRule() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Omit<Rule, 'id' | 'createdAt' | 'updatedAt'>) =>
|
||||
api.rules.create(data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['rules', 'list'] });
|
||||
toast.success('Rule Created', { description: 'The rule has been created successfully.' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Create Rule', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRule() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Rule> }) =>
|
||||
api.rules.update(id, data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['rules', 'list'] });
|
||||
toast.success('Rule Updated', { description: 'The rule has been updated successfully.' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Update Rule', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRule() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.rules.delete(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['rules', 'list'] });
|
||||
toast.success('Rule Deleted', { description: 'The rule has been deleted successfully.' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Delete Rule', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useToggleRule() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||
api.rules.update(id, { isActive }),
|
||||
onMutate: async ({ id, isActive }) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['rules', 'list'] });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousRules = queryClient.getQueryData<Rule[]>(['rules', 'list']);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData<Rule[]>(['rules', 'list'], (old) => {
|
||||
if (!old) return [];
|
||||
return old.map((rule) =>
|
||||
rule.id === id ? { ...rule, isActive } : rule
|
||||
);
|
||||
});
|
||||
|
||||
return { previousRules };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousRules) {
|
||||
queryClient.setQueryData(['rules', 'list'], context.previousRules);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['rules', 'list'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
162
apps/web/src/hooks/queries/useServers.ts
Normal file
162
apps/web/src/hooks/queries/useServers.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SERVER_STATS_CONFIG, type ServerResourceDataPoint } from '@tracearr/shared';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
import { useRef, useCallback } from 'react';
|
||||
|
||||
export function useServers() {
|
||||
return useQuery({
|
||||
queryKey: ['servers', 'list'],
|
||||
queryFn: api.servers.list,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; type: string; url: string; token: string }) =>
|
||||
api.servers.create(data),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['servers', 'list'] });
|
||||
toast.success('Server Added', { description: 'The server has been added successfully.' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Add Server', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.servers.delete(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['servers', 'list'] });
|
||||
toast.success('Server Removed', { description: 'The server has been removed successfully.' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Remove Server', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSyncServer() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.servers.sync(id),
|
||||
onSuccess: (data) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['servers', 'list'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['users', 'list'] });
|
||||
|
||||
// Show detailed results
|
||||
const parts: string[] = [];
|
||||
if (data.usersAdded > 0) parts.push(`${data.usersAdded} users added`);
|
||||
if (data.usersUpdated > 0) parts.push(`${data.usersUpdated} users updated`);
|
||||
if (data.librariesSynced > 0) parts.push(`${data.librariesSynced} libraries`);
|
||||
if (data.errors.length > 0) parts.push(`${data.errors.length} errors`);
|
||||
|
||||
const description = parts.length > 0
|
||||
? parts.join(', ')
|
||||
: 'No changes detected';
|
||||
|
||||
if (data.errors.length > 0) {
|
||||
toast.warning(data.success ? 'Sync Completed with Errors' : 'Sync Completed with Errors', { description });
|
||||
// Log errors to console for debugging
|
||||
console.error('Sync errors:', data.errors);
|
||||
} else {
|
||||
toast.success('Server Synced', { description });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Sync Failed', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching server resource statistics with fixed 2-minute window
|
||||
* Polls every 10 seconds, displays last 2 minutes of data (12 points)
|
||||
* X-axis is static (2m → NOW), data slides through as new points arrive
|
||||
*
|
||||
* @param serverId - Server ID to fetch stats for
|
||||
* @param enabled - Whether polling is enabled (typically tied to component mount)
|
||||
*/
|
||||
export function useServerStatistics(serverId: string | undefined, enabled: boolean = true) {
|
||||
// Accumulate data points across polls, keyed by timestamp for deduplication
|
||||
const dataMapRef = useRef<Map<number, ServerResourceDataPoint>>(new Map());
|
||||
|
||||
// Merge new data with existing, keep most recent DATA_POINTS
|
||||
const mergeData = useCallback((newData: ServerResourceDataPoint[]) => {
|
||||
const map = dataMapRef.current;
|
||||
|
||||
// Add/update data points
|
||||
for (const point of newData) {
|
||||
map.set(point.at, point);
|
||||
}
|
||||
|
||||
// Sort by timestamp descending (newest first), keep DATA_POINTS
|
||||
const sorted = Array.from(map.values())
|
||||
.sort((a, b) => b.at - a.at)
|
||||
.slice(0, SERVER_STATS_CONFIG.DATA_POINTS);
|
||||
|
||||
// Rebuild map with only kept points
|
||||
dataMapRef.current = new Map(sorted.map((p) => [p.at, p]));
|
||||
|
||||
// Return in ascending order (oldest first) for chart rendering
|
||||
return sorted.reverse();
|
||||
}, []);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['servers', 'statistics', serverId],
|
||||
queryFn: async () => {
|
||||
if (!serverId) throw new Error('Server ID required');
|
||||
const response = await api.servers.statistics(serverId);
|
||||
// Merge with accumulated data
|
||||
const mergedData = mergeData(response.data);
|
||||
return {
|
||||
...response,
|
||||
data: mergedData,
|
||||
};
|
||||
},
|
||||
enabled: enabled && !!serverId,
|
||||
// Poll every 10 seconds
|
||||
refetchInterval: SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000,
|
||||
// Don't poll when tab is hidden
|
||||
refetchIntervalInBackground: false,
|
||||
// Don't refetch on window focus (we have interval polling)
|
||||
refetchOnWindowFocus: false,
|
||||
// Keep previous data while fetching new
|
||||
placeholderData: (prev) => prev,
|
||||
// Data is fresh until next poll
|
||||
staleTime: (SERVER_STATS_CONFIG.POLL_INTERVAL_SECONDS * 1000) - 500,
|
||||
});
|
||||
|
||||
// Calculate averages from windowed data
|
||||
const dataPoints = query.data?.data as ServerResourceDataPoint[] | undefined;
|
||||
const dataLength = dataPoints?.length ?? 0;
|
||||
const averages = dataPoints && dataLength > 0
|
||||
? {
|
||||
hostCpu: Math.round(
|
||||
dataPoints.reduce((sum: number, p) => sum + p.hostCpuUtilization, 0) / dataLength
|
||||
),
|
||||
processCpu: Math.round(
|
||||
dataPoints.reduce((sum: number, p) => sum + p.processCpuUtilization, 0) / dataLength
|
||||
),
|
||||
hostMemory: Math.round(
|
||||
dataPoints.reduce((sum: number, p) => sum + p.hostMemoryUtilization, 0) / dataLength
|
||||
),
|
||||
processMemory: Math.round(
|
||||
dataPoints.reduce((sum: number, p) => sum + p.processMemoryUtilization, 0) / dataLength
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
...query,
|
||||
averages,
|
||||
};
|
||||
}
|
||||
37
apps/web/src/hooks/queries/useSessions.ts
Normal file
37
apps/web/src/hooks/queries/useSessions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import '@tracearr/shared';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface SessionsParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: string;
|
||||
serverId?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export function useSessions(params: SessionsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['sessions', 'list', params],
|
||||
queryFn: () => api.sessions.list(params),
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useActiveSessions(serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['sessions', 'active', serverId],
|
||||
queryFn: () => api.sessions.getActive(serverId ?? undefined),
|
||||
staleTime: 1000 * 15, // 15 seconds
|
||||
refetchInterval: 1000 * 30, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useSession(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['sessions', 'detail', id],
|
||||
queryFn: () => api.sessions.get(id),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
46
apps/web/src/hooks/queries/useSettings.ts
Normal file
46
apps/web/src/hooks/queries/useSettings.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Settings } from '@tracearr/shared';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useSettings() {
|
||||
return useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.settings.get,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<Settings>) => api.settings.update(data),
|
||||
onMutate: async (newSettings) => {
|
||||
// Cancel outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['settings'] });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousSettings = queryClient.getQueryData<Settings>(['settings']);
|
||||
|
||||
// Optimistically update to the new value
|
||||
queryClient.setQueryData<Settings>(['settings'], (old) => {
|
||||
if (!old) return old;
|
||||
return { ...old, ...newSettings };
|
||||
});
|
||||
|
||||
return { previousSettings };
|
||||
},
|
||||
onError: (err, newSettings, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousSettings) {
|
||||
queryClient.setQueryData(['settings'], context.previousSettings);
|
||||
}
|
||||
toast.error('Failed to Update Settings', { description: (err).message });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
toast.success('Settings Updated', { description: 'Your settings have been saved.' });
|
||||
},
|
||||
});
|
||||
}
|
||||
102
apps/web/src/hooks/queries/useStats.ts
Normal file
102
apps/web/src/hooks/queries/useStats.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import '@tracearr/shared';
|
||||
import { api, type StatsTimeRange } from '@/lib/api';
|
||||
|
||||
// Re-export for backwards compatibility and convenience
|
||||
export type { StatsTimeRange };
|
||||
|
||||
export function useDashboardStats(serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'dashboard', serverId],
|
||||
queryFn: () => api.stats.dashboard(serverId ?? undefined),
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
refetchInterval: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlaysStats(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'plays', timeRange, serverId],
|
||||
queryFn: () => api.stats.plays(timeRange ?? { period: 'week' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserStats(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'users', timeRange, serverId],
|
||||
queryFn: () => api.stats.users(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export interface LocationStatsFilters {
|
||||
timeRange?: StatsTimeRange;
|
||||
serverUserId?: string;
|
||||
serverId?: string;
|
||||
mediaType?: 'movie' | 'episode' | 'track';
|
||||
}
|
||||
|
||||
export function useLocationStats(filters?: LocationStatsFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'locations', filters],
|
||||
queryFn: () => api.stats.locations(filters),
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlaysByDayOfWeek(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'plays-by-dayofweek', timeRange, serverId],
|
||||
queryFn: () => api.stats.playsByDayOfWeek(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlaysByHourOfDay(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'plays-by-hourofday', timeRange, serverId],
|
||||
queryFn: () => api.stats.playsByHourOfDay(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlatformStats(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'platforms', timeRange, serverId],
|
||||
queryFn: () => api.stats.platforms(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useQualityStats(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'quality', timeRange, serverId],
|
||||
queryFn: () => api.stats.quality(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopUsers(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'top-users', timeRange, serverId],
|
||||
queryFn: () => api.stats.topUsers(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useTopContent(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'top-content', timeRange, serverId],
|
||||
queryFn: () => api.stats.topContent(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useConcurrentStats(timeRange?: StatsTimeRange, serverId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['stats', 'concurrent', timeRange, serverId],
|
||||
queryFn: () => api.stats.concurrent(timeRange ?? { period: 'month' }, serverId ?? undefined),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
23
apps/web/src/hooks/queries/useTerminateSession.ts
Normal file
23
apps/web/src/hooks/queries/useTerminateSession.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Mutation hook for terminating an active streaming session
|
||||
* Invalidates active sessions cache on success
|
||||
*/
|
||||
export function useTerminateSession() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ sessionId, reason }: { sessionId: string; reason?: string }) =>
|
||||
api.sessions.terminate(sessionId, reason),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||
toast.success('Stream Terminated', { description: 'The playback session has been stopped.' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Terminate', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
102
apps/web/src/hooks/queries/useUsers.ts
Normal file
102
apps/web/src/hooks/queries/useUsers.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export function useUsers(params: { page?: number; pageSize?: number; serverId?: string } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'list', params],
|
||||
queryFn: () => api.users.list(params),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUser(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'detail', id],
|
||||
queryFn: () => api.users.get(id),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate endpoint that fetches all user data in one request.
|
||||
* Use this for the UserDetail page instead of multiple separate queries.
|
||||
* Reduces 6 API calls to 1, significantly improving load time.
|
||||
*/
|
||||
export function useUserFull(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'full', id],
|
||||
queryFn: () => api.users.getFull(id),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserSessions(id: string, params: { page?: number; pageSize?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'sessions', id, params],
|
||||
queryFn: () => api.users.sessions(id, params),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUser() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: { trustScore?: number } }) =>
|
||||
api.users.update(id, data),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update user in cache
|
||||
queryClient.setQueryData(['users', 'detail', variables.id], data);
|
||||
// Invalidate users list
|
||||
void queryClient.invalidateQueries({ queryKey: ['users', 'list'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateUserIdentity() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, name }: { id: string; name: string | null }) =>
|
||||
api.users.updateIdentity(id, { name }),
|
||||
onSuccess: (_, variables) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['users', 'full', variables.id] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['users', 'list'] });
|
||||
toast.success('Display Name Updated');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Update', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserLocations(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'locations', id],
|
||||
queryFn: () => api.users.locations(id),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserDevices(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'devices', id],
|
||||
queryFn: () => api.users.devices(id),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
export function useUserTerminations(id: string, params: { page?: number; pageSize?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['users', 'terminations', id, params],
|
||||
queryFn: () => api.users.terminations(id, params),
|
||||
enabled: !!id,
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
});
|
||||
}
|
||||
83
apps/web/src/hooks/queries/useViolations.ts
Normal file
83
apps/web/src/hooks/queries/useViolations.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { ViolationWithDetails, PaginatedResponse, ViolationSeverity } from '@tracearr/shared';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface ViolationsParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: string;
|
||||
severity?: ViolationSeverity;
|
||||
acknowledged?: boolean;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export function useViolations(params: ViolationsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['violations', 'list', params],
|
||||
queryFn: () => api.violations.list(params),
|
||||
staleTime: 1000 * 30, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
export function useAcknowledgeViolation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.violations.acknowledge(id),
|
||||
onMutate: async (id) => {
|
||||
// Optimistic update
|
||||
await queryClient.cancelQueries({ queryKey: ['violations', 'list'] });
|
||||
|
||||
const previousData = queryClient.getQueriesData<PaginatedResponse<ViolationWithDetails>>({
|
||||
queryKey: ['violations', 'list'],
|
||||
});
|
||||
|
||||
// Update all matching queries
|
||||
queryClient.setQueriesData<PaginatedResponse<ViolationWithDetails>>(
|
||||
{ queryKey: ['violations', 'list'] },
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
...old,
|
||||
data: old.data.map((v) =>
|
||||
v.id === id ? { ...v, acknowledgedAt: new Date() } : v
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onError: (err, id, context) => {
|
||||
// Rollback on error
|
||||
if (context?.previousData) {
|
||||
for (const [queryKey, data] of context.previousData) {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
}
|
||||
}
|
||||
toast.error('Failed to Acknowledge', { description: (err).message });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['violations'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
|
||||
toast.success('Violation Acknowledged', { description: 'The violation has been marked as acknowledged.' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissViolation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.violations.dismiss(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['violations'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
|
||||
toast.success('Violation Dismissed', { description: 'The violation has been dismissed.' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to Dismiss', { description: error.message });
|
||||
},
|
||||
});
|
||||
}
|
||||
19
apps/web/src/hooks/use-mobile.ts
Normal file
19
apps/web/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
174
apps/web/src/hooks/useAuth.tsx
Normal file
174
apps/web/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { AuthUser } from '@tracearr/shared';
|
||||
import { api, tokenStorage, AUTH_STATE_CHANGE_EVENT } from '@/lib/api';
|
||||
|
||||
interface UserProfile extends AuthUser {
|
||||
email: string | null;
|
||||
thumbUrl: string | null;
|
||||
trustScore: number;
|
||||
hasPassword?: boolean;
|
||||
hasPlexLinked?: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: UserProfile | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
logout: () => Promise<void>;
|
||||
refetch: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: userData,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: async () => {
|
||||
// Don't even try if no token
|
||||
if (!tokenStorage.getAccessToken()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const user = await api.auth.me();
|
||||
// Return full user profile including thumbUrl
|
||||
return {
|
||||
userId: user.userId ?? user.id ?? '',
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
serverIds: user.serverIds ?? (user.serverId ? [user.serverId] : []),
|
||||
email: user.email ?? null,
|
||||
thumbUrl: user.thumbnail ?? user.thumbUrl ?? null,
|
||||
trustScore: user.aggregateTrustScore ?? user.trustScore ?? 100,
|
||||
hasPassword: user.hasPassword,
|
||||
hasPlexLinked: user.hasPlexLinked,
|
||||
} as UserProfile;
|
||||
} catch {
|
||||
// Don't clear tokens on network errors (e.g., server restart)
|
||||
// The API layer already clears tokens on real auth failures (401 + failed refresh)
|
||||
// Just return null to indicate "not currently authenticated"
|
||||
return null;
|
||||
}
|
||||
},
|
||||
// Retry configuration following AWS best practices:
|
||||
// - 3 retries (industry standard)
|
||||
// - Exponential backoff with full jitter to prevent thundering herd
|
||||
// - Cap at 10s to prevent excessively long waits
|
||||
// - Only retry on network errors, not on 4xx auth errors (handled by API layer)
|
||||
retry: (failureCount, error) => {
|
||||
// Don't retry on auth errors (4xx) - API layer handles token refresh
|
||||
// Only retry on network errors (TypeError: fetch failed, etc.)
|
||||
if (error instanceof Error && error.message.includes('401')) return false;
|
||||
if (error instanceof Error && error.message.includes('403')) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
// Full jitter: random(0, min(cap, base * 2^attempt))
|
||||
// This spreads out retries to prevent all clients hitting server at once
|
||||
retryDelay: (attemptIndex) => {
|
||||
const baseDelay = 1000;
|
||||
const maxDelay = 10000;
|
||||
const exponentialDelay = Math.min(maxDelay, baseDelay * 2 ** attemptIndex);
|
||||
// Full jitter - random value between 0 and the exponential delay
|
||||
return Math.random() * exponentialDelay;
|
||||
},
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
// Auto-refetch when network reconnects (handles stale tabs)
|
||||
refetchOnReconnect: true,
|
||||
// Refetch when window regains focus (handles stale tabs)
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
// Listen for auth state changes (e.g., token cleared due to failed refresh)
|
||||
useEffect(() => {
|
||||
const handleAuthChange = () => {
|
||||
// Immediately clear auth data and redirect to login
|
||||
queryClient.setQueryData(['auth', 'me'], null);
|
||||
queryClient.clear();
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handleAuthChange);
|
||||
return () => window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handleAuthChange);
|
||||
}, [queryClient]);
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
try {
|
||||
await api.auth.logout();
|
||||
} catch {
|
||||
// Ignore API errors - we're logging out anyway
|
||||
} finally {
|
||||
// Use silent mode to avoid double-redirect (we handle redirect in onSettled)
|
||||
tokenStorage.clearTokens(true);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
// Always redirect, whether success or failure
|
||||
queryClient.setQueryData(['auth', 'me'], null);
|
||||
queryClient.clear();
|
||||
window.location.href = '/login';
|
||||
},
|
||||
});
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutMutation.mutateAsync();
|
||||
}, [logoutMutation]);
|
||||
|
||||
// Optimistic authentication pattern (industry standard):
|
||||
// - If we have tokens in localStorage, assume authenticated until tokens are cleared
|
||||
// - Tokens only get cleared on explicit 401/403 from the server
|
||||
// - This prevents "logout" during temporary server unavailability (restarts, network issues)
|
||||
// See: https://github.com/TanStack/query/discussions/1547
|
||||
const hasTokens = !!tokenStorage.getAccessToken();
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user: userData ?? null,
|
||||
isLoading,
|
||||
// Optimistic: authenticated if we have tokens (server might just be temporarily down)
|
||||
// Only false when tokens are explicitly cleared (logout or 401/403 rejection)
|
||||
isAuthenticated: hasTokens,
|
||||
logout,
|
||||
refetch,
|
||||
}),
|
||||
[userData, isLoading, hasTokens, logout, refetch]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Hook for protected routes
|
||||
export function useRequireAuth(): AuthContextValue {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
// isAuthenticated is now token-based (optimistic auth)
|
||||
// So this only triggers when tokens don't exist (never logged in, or explicitly logged out)
|
||||
if (!auth.isAuthenticated) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
return auth;
|
||||
}
|
||||
76
apps/web/src/hooks/useEstimatedProgress.ts
Normal file
76
apps/web/src/hooks/useEstimatedProgress.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import type { ActiveSession } from '@tracearr/shared';
|
||||
|
||||
/**
|
||||
* Hook that estimates playback progress client-side for smooth UI updates.
|
||||
*
|
||||
* When state is "playing", progress increments every second based on elapsed time.
|
||||
* When state is "paused" or "stopped", progress stays at last known value.
|
||||
*
|
||||
* Resets estimation when:
|
||||
* - Session ID changes
|
||||
* - Server-side progressMs changes (new data from SSE/poll)
|
||||
* - State changes
|
||||
*
|
||||
* @param session - The active session to estimate progress for
|
||||
* @returns Object with estimated progressMs and progress percentage
|
||||
*/
|
||||
export function useEstimatedProgress(session: ActiveSession) {
|
||||
const [estimatedProgressMs, setEstimatedProgressMs] = useState(session.progressMs ?? 0);
|
||||
|
||||
// Track the last known server values to detect changes
|
||||
const lastServerProgress = useRef(session.progressMs);
|
||||
const lastSessionId = useRef(session.id);
|
||||
const lastState = useRef(session.state);
|
||||
const estimationStartTime = useRef(Date.now());
|
||||
const estimationStartProgress = useRef(session.progressMs ?? 0);
|
||||
|
||||
// Reset estimation when server data changes
|
||||
useEffect(() => {
|
||||
const serverProgressChanged = session.progressMs !== lastServerProgress.current;
|
||||
const sessionChanged = session.id !== lastSessionId.current;
|
||||
const stateChanged = session.state !== lastState.current;
|
||||
|
||||
if (sessionChanged || serverProgressChanged || stateChanged) {
|
||||
// Reset to server value
|
||||
const newProgress = session.progressMs ?? 0;
|
||||
setEstimatedProgressMs(newProgress);
|
||||
|
||||
// Update refs
|
||||
lastServerProgress.current = session.progressMs;
|
||||
lastSessionId.current = session.id;
|
||||
lastState.current = session.state;
|
||||
estimationStartTime.current = Date.now();
|
||||
estimationStartProgress.current = newProgress;
|
||||
}
|
||||
}, [session.id, session.progressMs, session.state]);
|
||||
|
||||
// Tick progress when playing
|
||||
useEffect(() => {
|
||||
if (session.state !== 'playing') {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
const elapsedMs = Date.now() - estimationStartTime.current;
|
||||
const estimated = estimationStartProgress.current + elapsedMs;
|
||||
|
||||
// Cap at total duration if available
|
||||
const maxProgress = session.totalDurationMs ?? Infinity;
|
||||
setEstimatedProgressMs(Math.min(estimated, maxProgress));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [session.state, session.totalDurationMs]);
|
||||
|
||||
// Calculate percentage
|
||||
const progressPercent = session.totalDurationMs
|
||||
? Math.min((estimatedProgressMs / session.totalDurationMs) * 100, 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
estimatedProgressMs,
|
||||
progressPercent,
|
||||
isEstimating: session.state === 'playing',
|
||||
};
|
||||
}
|
||||
142
apps/web/src/hooks/useServer.tsx
Normal file
142
apps/web/src/hooks/useServer.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Server } from '@tracearr/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
// Local storage key for persisting selected server
|
||||
const SELECTED_SERVER_KEY = 'tracearr_selected_server';
|
||||
|
||||
interface ServerContextValue {
|
||||
servers: Server[];
|
||||
selectedServer: Server | null;
|
||||
selectedServerId: string | null;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
selectServer: (serverId: string) => void;
|
||||
refetch: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
const ServerContext = createContext<ServerContextValue | null>(null);
|
||||
|
||||
export function ServerProvider({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(() => {
|
||||
// Initialize from localStorage
|
||||
return localStorage.getItem(SELECTED_SERVER_KEY);
|
||||
});
|
||||
|
||||
// Fetch available servers (only when authenticated)
|
||||
const {
|
||||
data: servers = [],
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['servers'],
|
||||
queryFn: () => api.servers.list(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
// Filter servers by user's accessible serverIds (non-owners only see assigned servers)
|
||||
const accessibleServers = useMemo(() => {
|
||||
if (!user) return [];
|
||||
if (user.role === 'owner') return servers;
|
||||
return servers.filter((s) => user.serverIds.includes(s.id));
|
||||
}, [servers, user]);
|
||||
|
||||
// Validate and auto-select server when servers load
|
||||
useEffect(() => {
|
||||
// Don't do anything while still loading servers or waiting for user data
|
||||
// This prevents clearing selection before we have the full picture
|
||||
if (isLoading || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessibleServers.length === 0) {
|
||||
// No servers available after loading, clear selection
|
||||
if (selectedServerId) {
|
||||
setSelectedServerId(null);
|
||||
localStorage.removeItem(SELECTED_SERVER_KEY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If selected server is not in accessible list, select first available
|
||||
const currentServerValid = selectedServerId && accessibleServers.some((s) => s.id === selectedServerId);
|
||||
if (!currentServerValid) {
|
||||
const firstServer = accessibleServers[0];
|
||||
if (firstServer) {
|
||||
setSelectedServerId(firstServer.id);
|
||||
localStorage.setItem(SELECTED_SERVER_KEY, firstServer.id);
|
||||
}
|
||||
}
|
||||
}, [accessibleServers, selectedServerId, isLoading, user]);
|
||||
|
||||
// Clear selection on logout
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
setSelectedServerId(null);
|
||||
localStorage.removeItem(SELECTED_SERVER_KEY);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const selectServer = useCallback((serverId: string) => {
|
||||
setSelectedServerId(serverId);
|
||||
localStorage.setItem(SELECTED_SERVER_KEY, serverId);
|
||||
// Invalidate server-dependent queries to force refetch with new server context
|
||||
// We exclude 'servers' query as that's not server-dependent
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const key = query.queryKey[0];
|
||||
// Keep server list, invalidate everything else that may be server-specific
|
||||
return key !== 'servers';
|
||||
},
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
// Get the full server object for the selected ID
|
||||
const selectedServer = useMemo(() => {
|
||||
if (!selectedServerId) return null;
|
||||
return accessibleServers.find((s) => s.id === selectedServerId) ?? null;
|
||||
}, [accessibleServers, selectedServerId]);
|
||||
|
||||
const value = useMemo<ServerContextValue>(
|
||||
() => ({
|
||||
servers: accessibleServers,
|
||||
selectedServer,
|
||||
selectedServerId,
|
||||
isLoading,
|
||||
isFetching,
|
||||
selectServer,
|
||||
refetch,
|
||||
}),
|
||||
[accessibleServers, selectedServer, selectedServerId, isLoading, isFetching, selectServer, refetch]
|
||||
);
|
||||
|
||||
return <ServerContext.Provider value={value}>{children}</ServerContext.Provider>;
|
||||
}
|
||||
|
||||
export function useServer(): ServerContextValue {
|
||||
const context = useContext(ServerContext);
|
||||
if (!context) {
|
||||
throw new Error('useServer must be used within a ServerProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Convenience hook to get just the selected server ID (common use case)
|
||||
export function useSelectedServerId(): string | null {
|
||||
const { selectedServerId } = useServer();
|
||||
return selectedServerId;
|
||||
}
|
||||
196
apps/web/src/hooks/useSocket.tsx
Normal file
196
apps/web/src/hooks/useSocket.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
ServerToClientEvents,
|
||||
ClientToServerEvents,
|
||||
ActiveSession,
|
||||
ViolationWithDetails,
|
||||
DashboardStats,
|
||||
NotificationChannelRouting,
|
||||
NotificationEventType,
|
||||
} from '@tracearr/shared';
|
||||
import { WS_EVENTS } from '@tracearr/shared';
|
||||
import { useAuth } from './useAuth';
|
||||
import { toast } from 'sonner';
|
||||
import { tokenStorage } from '@/lib/api';
|
||||
import { useChannelRouting } from './queries';
|
||||
|
||||
type TypedSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||
|
||||
interface SocketContextValue {
|
||||
socket: TypedSocket | null;
|
||||
isConnected: boolean;
|
||||
subscribeSessions: () => void;
|
||||
unsubscribeSessions: () => void;
|
||||
}
|
||||
|
||||
const SocketContext = createContext<SocketContextValue | null>(null);
|
||||
|
||||
export function SocketProvider({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [socket, setSocket] = useState<TypedSocket | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
// Get channel routing for web toast preferences
|
||||
const { data: routingData } = useChannelRouting();
|
||||
|
||||
// Build a ref to the routing map for access in event handlers
|
||||
const routingMapRef = useRef<Map<NotificationEventType, NotificationChannelRouting>>(new Map());
|
||||
|
||||
// Update the ref when routing data changes
|
||||
useEffect(() => {
|
||||
const newMap = new Map<NotificationEventType, NotificationChannelRouting>();
|
||||
routingData?.forEach((r) => newMap.set(r.eventType, r));
|
||||
routingMapRef.current = newMap;
|
||||
}, [routingData]);
|
||||
|
||||
// Helper to check if web toast is enabled for an event type
|
||||
const isWebToastEnabled = useCallback((eventType: NotificationEventType): boolean => {
|
||||
const routing = routingMapRef.current.get(eventType);
|
||||
// Default to true if routing not yet loaded
|
||||
return routing?.webToastEnabled ?? true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setSocket(null);
|
||||
setIsConnected(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Get JWT token for authentication
|
||||
const token = tokenStorage.getAccessToken();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create socket connection with auth token
|
||||
const newSocket: TypedSocket = io({
|
||||
path: '/socket.io',
|
||||
withCredentials: true,
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
auth: {
|
||||
token,
|
||||
},
|
||||
});
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
});
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
setIsConnected(false);
|
||||
});
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
console.error('[Socket] Connection error:', error);
|
||||
});
|
||||
|
||||
// Handle real-time events
|
||||
// Note: Since users can filter by server, we invalidate all matching query patterns
|
||||
// and let react-query refetch with the appropriate server filter
|
||||
newSocket.on(WS_EVENTS.SESSION_STARTED as 'session:started', (session: ActiveSession) => {
|
||||
// Invalidate all active sessions queries (regardless of server filter)
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||
// Invalidate dashboard stats and session history
|
||||
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions', 'list'] });
|
||||
|
||||
// Show toast if web notifications are enabled for stream_started
|
||||
if (isWebToastEnabled('stream_started')) {
|
||||
toast.info('New Stream Started', {
|
||||
description: `${session.user.identityName ?? session.user.username} is watching ${session.mediaTitle}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
newSocket.on(WS_EVENTS.SESSION_STOPPED as 'session:stopped', (_sessionId: string) => {
|
||||
// Invalidate all active sessions queries (regardless of server filter)
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||
// Invalidate dashboard stats and session history (stopped session now has duration)
|
||||
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions', 'list'] });
|
||||
});
|
||||
|
||||
newSocket.on(WS_EVENTS.SESSION_UPDATED as 'session:updated', (_session: ActiveSession) => {
|
||||
// Invalidate all active sessions queries (regardless of server filter)
|
||||
void queryClient.invalidateQueries({ queryKey: ['sessions', 'active'] });
|
||||
});
|
||||
|
||||
newSocket.on(WS_EVENTS.VIOLATION_NEW as 'violation:new', (violation: ViolationWithDetails) => {
|
||||
// Invalidate violations query
|
||||
void queryClient.invalidateQueries({ queryKey: ['violations'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
|
||||
|
||||
// Show toast notification if web notifications are enabled for violation_detected
|
||||
if (isWebToastEnabled('violation_detected')) {
|
||||
const toastFn = violation.severity === 'high' ? toast.error : toast.warning;
|
||||
toastFn(`New Violation: ${violation.rule.name}`, {
|
||||
description: `${violation.user.identityName ?? violation.user.username} triggered ${violation.rule.type}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
newSocket.on(WS_EVENTS.STATS_UPDATED as 'stats:updated', (_stats: DashboardStats) => {
|
||||
// Invalidate all dashboard stats queries (they now have server-specific cache keys)
|
||||
void queryClient.invalidateQueries({ queryKey: ['stats', 'dashboard'] });
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
return () => {
|
||||
newSocket.disconnect();
|
||||
};
|
||||
}, [isAuthenticated, queryClient, isWebToastEnabled]);
|
||||
|
||||
const subscribeSessions = useCallback(() => {
|
||||
if (socket && isConnected) {
|
||||
socket.emit('subscribe:sessions');
|
||||
}
|
||||
}, [socket, isConnected]);
|
||||
|
||||
const unsubscribeSessions = useCallback(() => {
|
||||
if (socket && isConnected) {
|
||||
socket.emit('unsubscribe:sessions');
|
||||
}
|
||||
}, [socket, isConnected]);
|
||||
|
||||
const value = useMemo<SocketContextValue>(
|
||||
() => ({
|
||||
socket,
|
||||
isConnected,
|
||||
subscribeSessions,
|
||||
unsubscribeSessions,
|
||||
}),
|
||||
[socket, isConnected, subscribeSessions, unsubscribeSessions]
|
||||
);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>{children}</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSocket(): SocketContextValue {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useSocket must be used within a SocketProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
76
apps/web/src/hooks/useTimeRange.ts
Normal file
76
apps/web/src/hooks/useTimeRange.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { TimeRangePeriod, TimeRangeValue } from '@/components/ui/time-range-picker';
|
||||
|
||||
const DEFAULT_PERIOD: TimeRangePeriod = 'month';
|
||||
|
||||
function parseDate(dateStr: string | null): Date | undefined {
|
||||
if (!dateStr) return undefined;
|
||||
const date = new Date(dateStr);
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
|
||||
function formatDateParam(date: Date | undefined): string | undefined {
|
||||
if (!date) return undefined;
|
||||
return date.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export function useTimeRange() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const value = useMemo<TimeRangeValue>(() => {
|
||||
const period = (searchParams.get('period') as TimeRangePeriod) || DEFAULT_PERIOD;
|
||||
|
||||
if (period === 'custom') {
|
||||
const startDate = parseDate(searchParams.get('from'));
|
||||
const endDate = parseDate(searchParams.get('to'));
|
||||
|
||||
// If custom period but missing dates, fall back to default
|
||||
if (!startDate || !endDate) {
|
||||
return { period: DEFAULT_PERIOD };
|
||||
}
|
||||
|
||||
return { period, startDate, endDate };
|
||||
}
|
||||
|
||||
return { period };
|
||||
}, [searchParams]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(newValue: TimeRangeValue) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
|
||||
params.set('period', newValue.period);
|
||||
|
||||
if (newValue.period === 'custom' && newValue.startDate && newValue.endDate) {
|
||||
params.set('from', formatDateParam(newValue.startDate)!);
|
||||
params.set('to', formatDateParam(newValue.endDate)!);
|
||||
} else {
|
||||
params.delete('from');
|
||||
params.delete('to');
|
||||
}
|
||||
|
||||
return params;
|
||||
},
|
||||
{ replace: true }
|
||||
);
|
||||
},
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
// Helper to get API params for backend calls
|
||||
const apiParams = useMemo(() => {
|
||||
if (value.period === 'custom' && value.startDate && value.endDate) {
|
||||
return {
|
||||
period: value.period,
|
||||
startDate: value.startDate.toISOString(),
|
||||
endDate: value.endDate.toISOString(),
|
||||
};
|
||||
}
|
||||
return { period: value.period };
|
||||
}, [value]);
|
||||
|
||||
return { value, setValue, apiParams };
|
||||
}
|
||||
Reference in New Issue
Block a user