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

This commit is contained in:
2025-12-17 12:32:50 +13:00
commit 3015f48118
471 changed files with 141143 additions and 0 deletions

View 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';

View 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';

View 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'] });
},
});
}

View 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 });
},
});
}

View 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'] });
},
});
}

View 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,
};
}

View 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
});
}

View 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.' });
},
});
}

View 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
});
}

View 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 });
},
});
}

View 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
});
}

View 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 });
},
});
}

View 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
}

View 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;
}

View 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',
};
}

View 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;
}

View 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;
}

View 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 };
}