diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 07b6bd36..fbc5b251 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -11,8 +11,8 @@ export const apiClient = axios.create({ }, }) -// Global error handler - shows toast for common API errors -// Pages can still catch errors explicitly if they need custom handling +// Global error handler for shared cases only. +// By convention, 4xx errors are handled at the page/component level. function handleGlobalError(error: AxiosError) { // Network error (no response from server) if (!error.response) { @@ -43,9 +43,8 @@ function handleGlobalError(error: AxiosError) { return } - // Client errors (4xx) — don't toast globally. - // Pages handle their own 4xx errors (permission checks, validation, not-found) - // and many are caught silently. Global toasts here cause noisy duplicates. + // Client errors (4xx) remain page-owned to avoid duplicate/noisy toasts. + // Global handling only covers 401/429/5xx. if (status >= 400 && status < 500) { return } diff --git a/frontend/src/components/admin/PageHeader.tsx b/frontend/src/components/admin/PageHeader.tsx index d282bcdc..8856ac13 100644 --- a/frontend/src/components/admin/PageHeader.tsx +++ b/frontend/src/components/admin/PageHeader.tsx @@ -1,25 +1,2 @@ -import type { ReactNode } from 'react' -import { cn } from '@/lib/utils' - -interface PageHeaderProps { - title: string - description?: string - action?: ReactNode - className?: string -} - -export function PageHeader({ title, description, action, className }: PageHeaderProps) { - return ( -
-
-

{title}

- {description && ( -

{description}

- )} -
- {action &&
{action}
} -
- ) -} - -export default PageHeader +export { PageHeader } from '@/components/common/PageHeader' +export { PageHeader as default } from '@/components/common/PageHeader' diff --git a/frontend/src/components/common/PageHeader.tsx b/frontend/src/components/common/PageHeader.tsx new file mode 100644 index 00000000..ee22d06c --- /dev/null +++ b/frontend/src/components/common/PageHeader.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from 'react' +import { cn } from '@/lib/utils' + +interface PageHeaderProps { + title: string + description?: string + icon?: ReactNode + action?: ReactNode + className?: string + titleClassName?: string + descriptionClassName?: string +} + +export function PageHeader({ + title, + description, + icon, + action, + className, + titleClassName, + descriptionClassName, +}: PageHeaderProps) { + return ( +
+
+ {icon &&
{icon}
} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ {action &&
{action}
} +
+ ) +} + +export default PageHeader diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 9e6671bf..55519f45 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react' import { Outlet, useLocation, useNavigate, Link } from 'react-router-dom' -import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, LogOut, Shield } from 'lucide-react' +import { Menu, X, LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Settings, LogOut, Shield } from 'lucide-react' import { useAuthStore } from '@/store/authStore' import { usePermissions } from '@/hooks/usePermissions' import { useUserPreferencesStore } from '@/store/userPreferencesStore' @@ -55,8 +55,7 @@ export function AppLayout() { { path: '/sessions', label: 'Sessions', icon: Clock }, { path: '/shares', label: 'Exports', icon: FileText }, { path: '/step-library', label: 'Step Library', icon: Bookmark }, - { path: '/account', label: 'Team', icon: Users }, - { path: '/account', label: 'Settings', icon: Settings }, + { path: '/account', label: 'Account', icon: Settings }, ] return ( diff --git a/frontend/src/components/layout/ProtectedRoute.tsx b/frontend/src/components/layout/ProtectedRoute.tsx index 0a2b5dae..cc8bf2cf 100644 --- a/frontend/src/components/layout/ProtectedRoute.tsx +++ b/frontend/src/components/layout/ProtectedRoute.tsx @@ -1,6 +1,7 @@ import { Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from '@/store/authStore' import { usePermissions, type EffectiveRole } from '@/hooks/usePermissions' +import { Spinner } from '@/components/common/Spinner' interface ProtectedRouteProps { requiredRole?: EffectiveRole @@ -15,7 +16,7 @@ export function ProtectedRoute({ requiredRole, children }: ProtectedRouteProps) if (isLoading) { return (
-
+
) } diff --git a/frontend/src/components/maintenance/BatchLaunchModal.tsx b/frontend/src/components/maintenance/BatchLaunchModal.tsx index 46c091c9..43a16845 100644 --- a/frontend/src/components/maintenance/BatchLaunchModal.tsx +++ b/frontend/src/components/maintenance/BatchLaunchModal.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' import { targetListsApi, batchLaunchApi } from '@/api' import type { TargetList, TargetEntry } from '@/types' +import { Spinner } from '@/components/common/Spinner' interface BatchLaunchModalProps { treeId: string @@ -127,7 +128,7 @@ export function BatchLaunchModal({ treeId, treeName, onClose, onLaunched }: Batc
{savedLists === null ? (
-
+
) : savedLists.length === 0 ? (

diff --git a/frontend/src/components/session/ShareSessionModal.tsx b/frontend/src/components/session/ShareSessionModal.tsx index 19b1e94d..3d6d0d0a 100644 --- a/frontend/src/components/session/ShareSessionModal.tsx +++ b/frontend/src/components/session/ShareSessionModal.tsx @@ -5,6 +5,7 @@ import { sessionsApi } from '@/api/sessions' import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare' import { cn } from '@/lib/utils' import { toast } from '@/lib/toast' +import { Spinner } from '@/components/common/Spinner' interface ShareSessionModalProps { sessionId: string @@ -406,7 +407,7 @@ export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: {/* Loading state */} {isLoadingShares && shares.length === 0 && (

-
+
)}
diff --git a/frontend/src/components/step-library/CustomStepModal.tsx b/frontend/src/components/step-library/CustomStepModal.tsx index 6a6b6218..a232ed3f 100644 --- a/frontend/src/components/step-library/CustomStepModal.tsx +++ b/frontend/src/components/step-library/CustomStepModal.tsx @@ -5,6 +5,7 @@ import { usePermissions } from '@/hooks/usePermissions' import { StepForm } from './StepForm' import { StepLibraryBrowser } from './StepLibraryBrowser' import type { Step, StepCreate } from '@/types/step' +import { Spinner } from '@/components/common/Spinner' export interface CustomStepDraft { title: string @@ -134,7 +135,7 @@ export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepMod {isSubmitting && (
-
+

Creating step...

diff --git a/frontend/src/components/tree-editor/TreeEditorLayout.tsx b/frontend/src/components/tree-editor/TreeEditorLayout.tsx index 939a4c6f..94221c9d 100644 --- a/frontend/src/components/tree-editor/TreeEditorLayout.tsx +++ b/frontend/src/components/tree-editor/TreeEditorLayout.tsx @@ -5,6 +5,7 @@ import { NodeEditorPanel } from './NodeEditorPanel' import { MetadataSidePanel } from './MetadataSidePanel' import { useTreeEditorStore } from '@/store/treeEditorStore' import { cn } from '@/lib/utils' +import { Spinner } from '@/components/common/Spinner' // Lazy load CodeModeEditor (Monaco is ~2MB) const CodeModeEditor = lazy(() => @@ -46,7 +47,7 @@ export function TreeEditorLayout({ )}> -
+
}> diff --git a/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx b/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx index 6d8c127e..8f046c07 100644 --- a/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx +++ b/frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx @@ -9,6 +9,7 @@ import { createCompletionProvider } from './resolutionFlowCompletions' import { CodeModeToolbar } from './CodeModeToolbar' import { SyntaxHelpPanel } from './SyntaxHelpPanel' import { setMonacoEditor } from './monacoEditorRef' +import { Spinner } from '@/components/common/Spinner' export function CodeModeEditor() { const editorRef = useRef(null) @@ -167,7 +168,7 @@ export function CodeModeEditor() { onMount={handleEditorDidMount} loading={
-
+
} options={{ diff --git a/frontend/src/pages/AccountSettingsPage.tsx b/frontend/src/pages/AccountSettingsPage.tsx index 6266f44c..d2bbbce7 100644 --- a/frontend/src/pages/AccountSettingsPage.tsx +++ b/frontend/src/pages/AccountSettingsPage.tsx @@ -138,12 +138,10 @@ export function AccountSettingsPage() { if (error) { return ( -
-
-
- - {error} -
+
+
+ + {error}
) @@ -152,7 +150,7 @@ export function AccountSettingsPage() { const sub = subscription?.subscription return ( -
+
diff --git a/frontend/src/pages/ForgotPasswordPage.tsx b/frontend/src/pages/ForgotPasswordPage.tsx index 1b1d200a..f4413ccb 100644 --- a/frontend/src/pages/ForgotPasswordPage.tsx +++ b/frontend/src/pages/ForgotPasswordPage.tsx @@ -35,7 +35,7 @@ export function ForgotPasswordPage() {
-

+

Reset Password

diff --git a/frontend/src/pages/MaintenanceFlowDetailPage.tsx b/frontend/src/pages/MaintenanceFlowDetailPage.tsx index 9875bd45..1b5c7303 100644 --- a/frontend/src/pages/MaintenanceFlowDetailPage.tsx +++ b/frontend/src/pages/MaintenanceFlowDetailPage.tsx @@ -5,6 +5,9 @@ import { treesApi } from '@/api/trees' import { sessionsApi } from '@/api/sessions' import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules' import { BatchLaunchModal } from '@/components/maintenance/BatchLaunchModal' +import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' +import { PageHeader } from '@/components/common/PageHeader' import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' import type { Tree, MaintenanceSchedule, Session } from '@/types' @@ -64,12 +67,29 @@ export default function MaintenanceFlowDetailPage() { if (isLoading) { return (

-
+
) } - if (!tree) return null + if (!tree) { + return ( +
+ navigate('/trees?type=maintenance')} + className="rounded-md border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" + > + Back to Maintenance Flows + + )} + /> +
+ ) + } // Group sessions by batch_id for run history const batchMap = new Map() @@ -81,43 +101,42 @@ export default function MaintenanceFlowDetailPage() { const batches = Array.from(batchMap.entries()).slice(0, 10) return ( -
+
{/* Header */} -
-
+
-
-

{tree.name}

- {tree.description && ( -

{tree.description}

- )} + )} + titleClassName="text-xl font-semibold" + action={( +
+ + +
-
-
- - - -
-
+ )} + /> {/* Schedule Panel */}
diff --git a/frontend/src/pages/MyAnalyticsPage.tsx b/frontend/src/pages/MyAnalyticsPage.tsx index e5aa3564..47cfa700 100644 --- a/frontend/src/pages/MyAnalyticsPage.tsx +++ b/frontend/src/pages/MyAnalyticsPage.tsx @@ -11,6 +11,7 @@ import { ResponsiveContainer, } from 'recharts' import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' import { analyticsApi } from '@/api' import { usePermissions } from '@/hooks/usePermissions' import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types' @@ -54,8 +55,11 @@ export default function MyAnalyticsPage() { if (!data) { return ( -
-

Failed to load analytics data.

+
+
) } diff --git a/frontend/src/pages/TeamAnalyticsPage.tsx b/frontend/src/pages/TeamAnalyticsPage.tsx index 950533aa..533d4809 100644 --- a/frontend/src/pages/TeamAnalyticsPage.tsx +++ b/frontend/src/pages/TeamAnalyticsPage.tsx @@ -11,8 +11,10 @@ import { ResponsiveContainer, } from 'recharts' import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' import { analyticsApi } from '@/api' import { usePermissions } from '@/hooks/usePermissions' +import { toast } from '@/lib/toast' import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types' const CHART_COLORS = { @@ -46,6 +48,12 @@ export default function TeamAnalyticsPage() { .finally(() => setLoading(false)) }, [period, isAccountOwner, isSuperAdmin]) + useEffect(() => { + if (!isAccountOwner && !isSuperAdmin) { + toast.info('Viewing your personal analytics', { id: 'analytics-redirect' }) + } + }, [isAccountOwner, isSuperAdmin]) + if (!isAccountOwner && !isSuperAdmin) { return } @@ -60,8 +68,11 @@ export default function TeamAnalyticsPage() { if (!data) { return ( -
-

Failed to load analytics data.

+
+
) } diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx index 59c9d90a..0132cbfe 100644 --- a/frontend/src/pages/TreeLibraryPage.tsx +++ b/frontend/src/pages/TreeLibraryPage.tsx @@ -21,6 +21,8 @@ import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore' import { useCachedQuota } from '@/hooks/useCachedQuota' import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal' import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown' +import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' import { toast } from '@/lib/toast' export function TreeLibraryPage() { @@ -465,13 +467,17 @@ export function TreeLibraryPage() { {/* Loading State */} {isLoading ? (
-
+
) : trees.length === 0 ? ( -
- No flows found.{' '} - {(searchQuery || hasActiveFilters) && 'Try adjusting your filters.'} -
+ ) : ( <> {treeLibraryView === 'grid' && ( diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx index 848ae3af..20f6e30f 100644 --- a/frontend/src/pages/TreeNavigationPage.tsx +++ b/frontend/src/pages/TreeNavigationPage.tsx @@ -795,7 +795,7 @@ export function TreeNavigationPage() { {index < 9 && ( selectingOption === option.id ? ( - + ) : ( diff --git a/frontend/src/pages/account/TargetListsPage.tsx b/frontend/src/pages/account/TargetListsPage.tsx index a0cf025a..17ced4e6 100644 --- a/frontend/src/pages/account/TargetListsPage.tsx +++ b/frontend/src/pages/account/TargetListsPage.tsx @@ -4,6 +4,10 @@ import { targetListsApi } from '@/api' import type { TargetList, TargetListCreate, TargetEntry } from '@/types' import { toast } from '@/lib/toast' import { ConfirmDialog } from '@/components/common/ConfirmDialog' +import { Modal } from '@/components/common/Modal' +import { Spinner } from '@/components/common/Spinner' +import { EmptyState } from '@/components/common/EmptyState' +import { PageHeader } from '@/components/common/PageHeader' export default function TargetListsPage() { const [lists, setLists] = useState([]) @@ -103,34 +107,31 @@ export default function TargetListsPage() { return (
-
-
-

Target Lists

-

- Saved server lists for maintenance flow batch launching -

-
- -
+ openEditor()} + className="flex items-center gap-1.5 rounded-lg bg-gradient-brand px-4 py-2 text-[0.875rem] font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90" + > + + New List + + )} + /> {isLoading ? (
-
+
) : lists.length === 0 ? ( -
- -

No target lists yet

-

- Create lists of servers to reuse across maintenance runs -

-
+ } + title="No target lists yet" + description="Create lists of servers to reuse across maintenance runs." + /> ) : (
{lists.map(list => ( @@ -172,68 +173,68 @@ export default function TargetListsPage() { )} {/* Editor Modal */} - {showEditor && ( -
-
-

- {editingList ? 'Edit Target List' : 'New Target List'} -

-
-
- - setEditorName(e.target.value)} - className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" - placeholder="e.g. RDS Farm A" - /> -
-
- - setEditorDescription(e.target.value)} - className="w-full rounded-lg border border-border bg-card px-3 py-2 text-[0.875rem] text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" - placeholder="e.g. Production RDS servers" - /> -
-
- -