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 005da143..ac609a6f 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 { preferred, savePreference } = usePreferredExportFormat() + 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) }) } @@ -121,21 +120,22 @@ export default function Reports() { {/* Loading State */} - {loading && ( -
-
-
- )} - - {/* Error State */} - {!loading && error && ( + + ) : error ? (
-
+

Archive_Retrieval_Failed

{error}

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

- Historical_Execution -

-

- {formatLocaleDate(createDate)} //{" "} - {formatLocaleTime(createDate)} -

-
- {task.duration_seconds && ( -
-

- {formatDuration( - task.duration_seconds, - )?.toUpperCase()} -

+ {/* Timeline Operations Feed */} +
+ {/* Vertical Timeline Cable */} +
+ + {/* Loading State */} + {isLoading && isInitialLoad ? ( +
+ +
+ ) : error ? ( +
+
+

Registry_Load_Failed

+

{error}

+
+ +
+ ) : tasks.length > 0 ? ( + + + {tasks.map((task) => { + const createDate = parseDateSafe(task.created_at); + const startDate = task.started_at ? parseDateSafe(task.started_at) : null; + const endDate = task.completed_at ? parseDateSafe(task.completed_at) : null; + + return ( + + {/* Timeline Node */} + + +
setExpandedId(expandedId === task.task_id ? null : task.task_id)} + > +
+
+
+
toggleSelection(task.task_id, e)} + className={`w-10 h-10 border-4 border-black flex items-center justify-center transition-all ${ + selectedIds.includes(task.task_id) + ? 'bg-rag-blue text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] -translate-x-1 -translate-y-1' + : 'bg-charcoal-dark text-silver/10 hover:border-rag-blue/40' + }`} + > + + {selectedIds.includes(task.task_id) ? 'check' : 'add'} + +
+ + {task.status} + + + OP_ID_{task.task_id.split('-')[0].toUpperCase()} + +
+ +
+

+ {task.tool} +

+

+ target + {task.target} +

+
+
+ +
+
+

Historical_Execution

+

+ {formatLocaleDate(createDate)} // {formatLocaleTime(createDate)} +

+
+ {task.duration_seconds && ( +
+

{formatDuration(task.duration_seconds)?.toUpperCase()}

+
+ )} +
+
+ + {/* Expandable Details Block */} + + {expandedId === task.task_id && ( + +
+
+
+ Signal_Metadata +
+
+

PLUGIN: {task.plugin_id}

+

SESSION: ENCRYPTED_VTX

+
+
+ +
+
+ Time_Matrix +
+
+
+ In_Lock + {startDate ? formatLocaleTime(startDate) : 'PENDING'} +
+
+ Release + {endDate ? formatLocaleTime(endDate) : 'N/A'} +
+
+
+ +
+ {(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && ( + + )} + {(task.status === 'completed' || task.status === 'failed') && ( + + )} + +
+
+
+ )} +
+
+
+ ); + })} +
+
+ ) : ( + navigate(routePath.toolkit) + }} + /> + )} +
+ + {/* Floating Bulk Action Bar */} + + {selectedIds.length > 0 && ( + +
+
+
{selectedIds.length}
+
+

Records_Selected_For_Pruning

+

Bulk_Action_Protocol_v4_Active

+
)}