From 9c8442315b44e11eb3c3596b383dcc47419209a1 Mon Sep 17 00:00:00 2001 From: Suyash Patil Date: Tue, 19 May 2026 21:55:21 +0530 Subject: [PATCH] Add loading skeletons and empty states --- frontend/src/components/EmptyState.tsx | 155 ++++++++++++ frontend/src/components/LoadingIndicator.tsx | 241 +++++++++++++++++++ frontend/src/components/LoadingSkeleton.tsx | 158 ++++++++++++ frontend/src/hooks/useLoadingState.ts | 229 ++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 19 +- frontend/src/pages/Reports.tsx | 64 ++--- frontend/src/pages/Scans.tsx | 58 +++-- 7 files changed, 866 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/EmptyState.tsx create mode 100644 frontend/src/components/LoadingIndicator.tsx create mode 100644 frontend/src/components/LoadingSkeleton.tsx create mode 100644 frontend/src/hooks/useLoadingState.ts diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 00000000..8272ace5 --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import { motion } from 'framer-motion' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Archive02Icon, + File01Icon, + Inbox01Icon, + ZapOffIcon, + AlertCircleIcon, +} from '@hugeicons/core-free-icons' + +interface EmptyStateProps { + type?: 'scans' | 'reports' | 'findings' | 'assets' | 'generic' + title?: string + description?: string + action?: { + label: string + onClick: () => void + } + className?: string +} + +function EmptyIcon({ + icon, + size = 48, + className = '', +}: { + icon: any + size?: number + className?: string +}) { + return +} + +const emptyStateConfigs = { + scans: { + icon: Inbox01Icon, + title: 'No Scans Available', + description: 'Start a new scan to begin analyzing your targets and identifying vulnerabilities.', + defaultAction: 'Start Scan' + }, + reports: { + icon: File01Icon, + title: 'No Reports Generated', + description: 'Complete a scan first to generate detailed security reports and findings.', + defaultAction: 'Run Scan' + }, + findings: { + icon: AlertCircleIcon, + title: 'No Findings Detected', + description: 'Great news! No security issues were found in this scan.', + defaultAction: 'View Scans' + }, + assets: { + icon: Archive02Icon, + title: 'No Assets Discovered', + description: 'Assets will appear here once scans complete and targets are identified.', + defaultAction: 'Start Discovery' + }, + generic: { + icon: ZapOffIcon, + title: 'No Data Available', + description: 'There is currently no data to display.', + defaultAction: 'Refresh' + } +} + +const containerVariants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { + opacity: 1, + scale: 1, + transition: { duration: 0.4, type: 'spring', stiffness: 200, damping: 20 } + } +} + +export default function EmptyState({ + type = 'generic', + title, + description, + action, + className = '' +}: EmptyStateProps) { + const config = emptyStateConfigs[type] + const displayTitle = title || config.title + const displayDescription = description || config.description + + return ( + +
+ {/* Icon Container */} +
+ +
+ + {/* Text Content */} +
+

+ {displayTitle} +

+

+ {displayDescription} +

+
+ + {/* Action Button */} + {action && ( + + {action.label} + + )} + + {/* Decorative Elements */} +
+

+ STATUS: IDLE // NO_ACTIVE_OPERATIONS +

+
+
+
+ ) +} + +export function EmptyStateInline({ + type = 'generic', + title, + description, + className = '' +}: Omit) { + const config = emptyStateConfigs[type] + const displayTitle = title || config.title + const displayDescription = description || config.description + + return ( +
+
+ +
+

{displayTitle}

+

{displayDescription}

+
+
+
+ ) +} diff --git a/frontend/src/components/LoadingIndicator.tsx b/frontend/src/components/LoadingIndicator.tsx new file mode 100644 index 00000000..08fc923a --- /dev/null +++ b/frontend/src/components/LoadingIndicator.tsx @@ -0,0 +1,241 @@ +import React from 'react' +import { motion } from 'framer-motion' +import { HugeiconsIcon } from '@hugeicons/react' +import { Refresh01Icon, LoaderIcon } from '@hugeicons/core-free-icons' + +interface LoadingIndicatorProps { + message?: string + size?: 'small' | 'medium' | 'large' + variant?: 'spinner' | 'dots' | 'bars' | 'pulse' + fullScreen?: boolean + className?: string +} + +function Icon({ + icon, + size = 32, + className = '', +}: { + icon: any + size?: number + className?: string +}) { + return +} + +const sizeMap = { + small: { icon: 24, container: 'py-8' }, + medium: { icon: 40, container: 'py-20' }, + large: { icon: 64, container: 'py-32' } +} + +const spinnerVariants = { + rotate: { + rotate: 360, + transition: { + duration: 1.5, + repeat: Infinity, + ease: 'linear' + } + } +} + +const dotsVariants = { + animate: { + transition: { + staggerChildren: 0.2 + } + } +} + +const dotVariants = { + animate: { + y: [-8, 0], + opacity: [0.5, 1], + transition: { + duration: 0.8, + repeat: Infinity, + repeatType: 'reverse' as const + } + } +} + +const barsVariants = { + animate: { + transition: { + staggerChildren: 0.1 + } + } +} + +const barVariants = { + animate: { + scaleY: [0.4, 1], + opacity: [0.5, 1], + transition: { + duration: 0.6, + repeat: Infinity, + repeatType: 'reverse' as const + } + } +} + +export function SpinnerIndicator({ size = 40 }: { size?: number }) { + return ( + + + + ) +} + +export function DotsIndicator({ size = 12 }: { size?: number }) { + return ( + + {[...Array(3)].map((_, i) => ( + + ))} + + ) +} + +export function BarsIndicator({ size = 24 }: { size?: number }) { + return ( + + {[...Array(4)].map((_, i) => ( + + ))} + + ) +} + +export function PulseIndicator() { + return ( + + ) +} + +export default function LoadingIndicator({ + message = 'Loading...', + size = 'medium', + variant = 'spinner', + fullScreen = false, + className = '' +}: LoadingIndicatorProps) { + const sizeConfig = sizeMap[size] + + const renderIndicator = () => { + switch (variant) { + case 'dots': + return + case 'bars': + return + case 'pulse': + return + default: + return + } + } + + const containerClass = fullScreen + ? 'fixed inset-0 flex items-center justify-center bg-charcoal-dark/80 backdrop-blur-sm z-50' + : `flex flex-col items-center justify-center gap-6 ${sizeConfig.container} ${className}` + + return ( +
+
+ {renderIndicator()} + {message && ( + + {message} + + )} +
+
+ ) +} + +// Progress indicator for multi-step operations +export function ProgressIndicator({ + current = 0, + total = 10, + message = 'Processing...' +}: { + current?: number + total?: number + message?: string +}) { + const percentage = (current / total) * 100 + + return ( +
+
+
+

+ {message} +

+ + {current} / {total} + +
+
+ +
+
+
+ ) +} + +// Skeleton with loading indicator overlay +export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) { + return ( + +
+ +

{message}

+
+
+ ) +} diff --git a/frontend/src/components/LoadingSkeleton.tsx b/frontend/src/components/LoadingSkeleton.tsx new file mode 100644 index 00000000..43078757 --- /dev/null +++ b/frontend/src/components/LoadingSkeleton.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import { motion } from 'framer-motion' + +interface LoadingSkeletonProps { + count?: number + type?: 'card' | 'list-item' | 'table-row' | 'metric' | 'chart' + className?: string +} + +const skeletonVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.1 } + } +} + +const itemVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 } +} + +const shimmer = { + animate: { + backgroundPosition: ['0% 0%', '100% 0%'] + } +} + +export function CardSkeleton() { + return ( +
+
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Content skeleton */} +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ + {/* Footer skeleton */} +
+
+
+
+
+
+ ) +} + +export function ListItemSkeleton() { + return ( +
+ {/* Timeline node skeleton */} +
+ +
+
+ {/* Top row skeleton */} +
+
+
+
+
+
+
+ + {/* Content skeleton */} +
+ {[1, 2].map((i) => ( +
+ ))} +
+
+
+
+ ) +} + +export function MetricSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+ ) +} + +export function TableRowSkeleton() { + return ( +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ) +} + +export function ChartSkeleton() { + return ( +
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+
+ ) +} + +export default function LoadingSkeleton({ count = 3, type = 'card', className = '' }: LoadingSkeletonProps) { + const renderSkeleton = () => { + switch (type) { + case 'list-item': + return + case 'table-row': + return + case 'metric': + return + case 'chart': + return + default: + return + } + } + + return ( + + {Array.from({ length: count }).map((_, i) => ( + + {renderSkeleton()} + + ))} + + ) +} diff --git a/frontend/src/hooks/useLoadingState.ts b/frontend/src/hooks/useLoadingState.ts new file mode 100644 index 00000000..d225f58e --- /dev/null +++ b/frontend/src/hooks/useLoadingState.ts @@ -0,0 +1,229 @@ +import { useState, useCallback, useRef, useEffect } from 'react' + +interface UseLoadingStateOptions { + delay?: number // Delay before showing loading state + minDuration?: number // Minimum time to show loading state +} + +interface UseLoadingStateReturn { + isLoading: boolean + isInitialLoad: boolean + error: string | null + startLoading: () => void + stopLoading: (error?: string | null) => void + setError: (error: string | null) => void + reset: () => void +} + +/** + * Custom hook for managing loading states with built-in delay and minimum duration + * Prevents UI flickering by delaying the loading state display + */ +export function useLoadingState( + options: UseLoadingStateOptions = {} +): UseLoadingStateReturn { + const { delay = 300, minDuration = 500 } = options + + const [isLoading, setIsLoading] = useState(false) + const [isInitialLoad, setIsInitialLoad] = useState(true) + const [error, setError] = useState(null) + + const delayTimerRef = useRef(null) + const minDurationTimerRef = useRef(null) + const loadingStartTimeRef = useRef(0) + + const startLoading = useCallback(() => { + // Clear any existing timers + if (delayTimerRef.current) clearTimeout(delayTimerRef.current) + if (minDurationTimerRef.current) clearTimeout(minDurationTimerRef.current) + + setError(null) + loadingStartTimeRef.current = Date.now() + + // Delay loading state to prevent flickering on fast operations + delayTimerRef.current = setTimeout(() => { + setIsLoading(true) + setIsInitialLoad(false) + }, delay) + }, [delay]) + + const stopLoading = useCallback((error: string | null = null) => { + // Clear delay timer if loading hasn't started yet + if (delayTimerRef.current) { + clearTimeout(delayTimerRef.current) + delayTimerRef.current = null + } + + if (error) { + setError(error) + setIsLoading(false) + setIsInitialLoad(false) + return + } + + // Calculate elapsed time and ensure minimum duration + const elapsed = Date.now() - loadingStartTimeRef.current + const remainingTime = Math.max(0, minDuration - elapsed) + + if (remainingTime > 0) { + minDurationTimerRef.current = setTimeout(() => { + setIsLoading(false) + setIsInitialLoad(false) + }, remainingTime) + } else { + setIsLoading(false) + setIsInitialLoad(false) + } + }, [minDuration]) + + const resetState = useCallback(() => { + if (delayTimerRef.current) clearTimeout(delayTimerRef.current) + if (minDurationTimerRef.current) clearTimeout(minDurationTimerRef.current) + + setIsLoading(false) + setIsInitialLoad(true) + setError(null) + }, []) + + // Cleanup timers on unmount + useEffect(() => { + return () => { + if (delayTimerRef.current) clearTimeout(delayTimerRef.current) + if (minDurationTimerRef.current) clearTimeout(minDurationTimerRef.current) + } + }, []) + + return { + isLoading, + isInitialLoad, + error, + startLoading, + stopLoading, + setError, + reset: resetState + } +} + +/** + * Custom hook for handling async operations with automatic loading state management + */ +export function useAsyncLoading( + asyncFn: () => Promise, + options: UseLoadingStateOptions = {} +) { + const loadingState = useLoadingState(options) + const [data, setData] = useState(null) + + const execute = useCallback(async () => { + loadingState.startLoading() + try { + const result = await asyncFn() + setData(result) + loadingState.stopLoading() + return result + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'An error occurred' + loadingState.stopLoading(errorMessage) + throw err + } + }, [asyncFn, loadingState]) + + return { + ...loadingState, + data, + execute, + setData + } +} + +/** + * Custom hook for managing multiple loading states (useful for independent requests) + */ +export function useMultipleLoadingStates( + keys: string[], + options: UseLoadingStateOptions = {} +) { + const [loadingStates, setLoadingStates] = useState>( + Object.fromEntries(keys.map(k => [k, false])) + ) + const [errors, setErrors] = useState>( + Object.fromEntries(keys.map(k => [k, null])) + ) + + const setLoading = useCallback((key: string, isLoading: boolean) => { + setLoadingStates(prev => ({ ...prev, [key]: isLoading })) + }, []) + + const setError = useCallback((key: string, error: string | null) => { + setErrors(prev => ({ ...prev, [key]: error })) + }, []) + + const isAnyLoading = Object.values(loadingStates).some(v => v) + const allErrors = Object.values(errors).filter(e => e !== null) + + return { + loadingStates, + errors, + setLoading, + setError, + isAnyLoading, + hasErrors: allErrors.length > 0, + allErrors + } +} + +/** + * Custom hook for handling paginated data fetching with loading states + */ +export function usePaginatedLoading( + fetchFn: (page: number, limit: number) => Promise, + options: UseLoadingStateOptions & { pageSize?: number } = {} +) { + const { pageSize = 10, ...loadingOptions } = options + const loadingState = useLoadingState(loadingOptions) + + const [data, setData] = useState([]) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + + const loadPage = useCallback(async (pageNum: number, append = false) => { + loadingState.startLoading() + try { + const result = await fetchFn(pageNum, pageSize) + setData(prev => append ? [...prev, ...result] : result) + setPage(pageNum) + setHasMore(result.length === pageSize) + loadingState.stopLoading() + return result + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Failed to load data' + loadingState.stopLoading(errorMessage) + throw err + } + }, [fetchFn, pageSize, loadingState]) + + const nextPage = useCallback(async () => { + if (!hasMore) return + await loadPage(page + 1, true) + }, [page, hasMore, loadPage]) + + const previousPage = useCallback(async () => { + if (page <= 1) return + await loadPage(page - 1, false) + }, [page, loadPage]) + + const reset = useCallback(async () => { + await loadPage(1, false) + }, [loadPage]) + + return { + ...loadingState, + data, + page, + hasMore, + loadPage, + nextPage, + previousPage, + reset + } +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b6b01a10..ec02e33a 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -5,6 +5,8 @@ import { getDashboardSummary, getHealth, cancelTask } from '../api' import { ExecutiveStatsBar } from '../components/ExecutiveStatsBar' import { routePath, routes } from '../routes' import { parseDateSafe, formatBriefingDate, formatTaskInit, formatLocaleDate } from '../utils/date' +import LoadingSkeleton, { CardSkeleton, MetricSkeleton } from '../components/LoadingSkeleton' +import { useLoadingState } from '../hooks/useLoadingState' type Finding = { id: string @@ -177,8 +179,7 @@ const itemVariants = { export default function Dashboard() { const [summary, setSummary] = useState(emptySummary) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const { isLoading, isInitialLoad, error, startLoading, stopLoading, setError } = useLoadingState({ delay: 300, minDuration: 500 }) const [backendConnected, setBackendConnected] = useState(null) const [lastSync, setLastSync] = useState(() => new Date().toISOString()) const navigate = useNavigate() @@ -188,13 +189,13 @@ export default function Dashboard() { const load = async () => { try { + startLoading() await getHealth() if (!cancelled) setBackendConnected(true) } catch { if (!cancelled) { setBackendConnected(false) - setError('Unable to reach the SecuScan backend') - setLoading(false) + stopLoading('Unable to reach the SecuScan backend') } return } @@ -205,13 +206,11 @@ export default function Dashboard() { setSummary(normalizeSummary(data as Partial)) setLastSync(new Date().toISOString()) setError(null) + stopLoading() }) .catch((err) => { if (cancelled) return - setError(err.message) - }) - .finally(() => { - if (!cancelled) setLoading(false) + stopLoading(err.message) }) } @@ -310,7 +309,7 @@ export default function Dashboard() {
- {loading ? ( + {isLoading && isInitialLoad ? ( Syncing operational data... - ) : error ? ( + ) : error ? ( ([]) const [summary, setSummary] = useState({ total_findings: 0, total_assets: 0, critical_findings: 0, high_findings: 0, total_attack_surface: 0 }) const [selectedType, setSelectedType] = useState('all') - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + const { isLoading, isInitialLoad, error, startLoading, stopLoading, setError } = useLoadingState({ delay: 300, minDuration: 500 }) const fetchReports = () => { - setLoading(true) - setError(null) + startLoading() Promise.all([getReports(), getDashboardSummary()]) .then(([reportData, summaryData]: any) => { setReports(reportData.reports || []) setSummary(summaryData || {}) + setError(null) + stopLoading() }) - .catch(() => { - setError('Failed to fetch reports') - }) - .finally(() => { - setLoading(false) + .catch((err) => { + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch reports' + stopLoading(errorMessage) }) } @@ -119,21 +120,22 @@ export default function Reports() { {/* Loading State */} - {loading && ( -
-
- + {isLoading && isInitialLoad ? ( + <> +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))}
-

- Retrieving Archive Data... -

-
- )} - - {/* Error State */} - {!loading && error && ( + + ) : error ? (
-
+

Archive_Retrieval_Failed

{error}

@@ -144,9 +146,7 @@ export default function Reports() { Retry
- )} - - {!loading && !error && ( + ) : ( <> {/* Metrics Row */}
@@ -311,12 +311,14 @@ export default function Reports() { ))} {filteredReports.length === 0 && ( -
- -
-

Archive Isolated

-

System buffer awaiting briefing generation protocols

-
+
+ navigate('/toolkit') + }} + />
)} diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 6328a061..6c22734a 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -4,6 +4,9 @@ import { motion, AnimatePresence } from 'framer-motion' import { API_BASE, deleteTask, clearAllTasks, bulkDeleteTasks } from '../api' import { routePath } from '../routes' import { parseDateSafe, formatLocaleDate, formatLocaleTime } from '../utils/date' +import LoadingSkeleton, { ListItemSkeleton } from '../components/LoadingSkeleton' +import EmptyState from '../components/EmptyState' +import { useLoadingState } from '../hooks/useLoadingState' interface Task { task_id: string @@ -48,7 +51,7 @@ const itemVariants = { export default function Scans() { const navigate = useNavigate() const [tasks, setTasks] = useState([]) - const [loading, setLoading] = useState(true) + const { isLoading, isInitialLoad, error, startLoading, stopLoading, setError } = useLoadingState({ delay: 300, minDuration: 500 }) const [filter, setFilter] = useState('all') const [expandedId, setExpandedId] = useState(null) const [selectedIds, setSelectedIds] = useState([]) @@ -61,6 +64,7 @@ export default function Scans() { async function loadTasks() { try { + startLoading() const url = filter === 'all' ? `${API_BASE}/tasks` : `${API_BASE}/tasks?status=${filter}` @@ -68,10 +72,12 @@ export default function Scans() { const res = await fetch(url) const data = await res.json() setTasks(data.tasks || []) + setError(null) + stopLoading() } catch (err) { console.error('Failed to load tasks:', err) - } finally { - setLoading(false) + const errorMessage = err instanceof Error ? err.message : 'Failed to load tasks' + stopLoading(errorMessage) } } @@ -180,8 +186,8 @@ export default function Scans() { Operational Registry

- Total_Registry_Keys: {tasks.length} // SYSTEM_STATUS: {loading ? 'SYNCING...' : 'SYNCED'} - + Total_Registry_Keys: {tasks.length} // SYSTEM_STATUS: {isLoading ? 'SYNCING...' : 'SYNCED'} +

@@ -246,8 +252,26 @@ export default function Scans() { {/* Vertical Timeline Cable */}
- - {tasks.length > 0 ? ( + {/* Loading State */} + {isLoading && isInitialLoad ? ( +
+ +
+ ) : error ? ( +
+
+

Registry_Load_Failed

+

{error}

+
+ +
+ ) : tasks.length > 0 ? ( + - ) : ( -
- inventory_2 -
-

Archive Isolated

-

No historical signal streams available for current selection

-
-
- )} -
+
+ ) : ( + navigate(routePath.toolkit) + }} + /> + )}
{/* Floating Bulk Action Bar */}