Skip to content

Commit c199ba4

Browse files
committed
refactor: redesign web UI to match Arr-style dark theme and improve UX
Complete frontend redesign following Radarr/Sonarr/Lidarr design patterns: ## UI/UX Improvements - Implement dark Arr-style theme with consistent color palette (#0d0d0d, #1a1a1a, #262626) - Redesign sidebar navigation with collapsible Settings submenu - Replace top header with integrated sidebar navigation (save vertical space) - Add clickable dashboard stat cards that navigate to filtered views - Implement skeleton loading states for dashboard metrics - Add confirmation dialogs for all destructive actions (protect/unprotect) - Enhance empty states with context-aware messages and helpful CTAs - Improve form layouts in Settings with better visual hierarchy - Add service status indicators with health checks ## Component Architecture - Replace AppHeader with new AppLayout component - Create ErrorBoundary for graceful error handling - Add ServiceStatusCard for integration health monitoring - Implement Skeleton component for loading states - Remove theme toggle (hardcoded dark theme for consistency) ## Error Handling & Resilience - Add comprehensive error boundary with user-friendly error UI - Create client-side error logging service with structured logging - Configure React Query with smart retry logic (exponential backoff) - Add error callbacks for query/mutation failures - Skip retries for 401/403/404, retry network errors up to 2 times ## Performance - Add memoization for expensive calculations (scheduledDeletionsCount) - Optimize query caching with proper staleTime and gcTime - Add loading skeletons to prevent layout shift ## API Changes - Add GET /api/services/status endpoint for health checks - Return service configuration and connectivity status ## Code Quality - Remove unused theme store - Clean up duplicate components and unused imports - Add comprehensive TODO comments for future server-side pagination - Fix TypeScript errors and improve type safety This refactor brings the UI in line with the Arr ecosystem's familiar interface while adding modern UX patterns like confirmations, better empty states, and comprehensive error handling.
1 parent b110760 commit c199ba4

20 files changed

Lines changed: 1925 additions & 1168 deletions

internal/api/handlers/services.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package handlers
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"sync"
8+
"time"
9+
10+
"github.com/ramonskie/oxicleanarr/internal/clients"
11+
"github.com/ramonskie/oxicleanarr/internal/config"
12+
)
13+
14+
// ServiceStatusHandler handles checking the status of connected services
15+
type ServiceStatusHandler struct {
16+
config *config.Config
17+
}
18+
19+
// NewServiceStatusHandler creates a new ServiceStatusHandler
20+
func NewServiceStatusHandler(cfg *config.Config) *ServiceStatusHandler {
21+
return &ServiceStatusHandler{
22+
config: cfg,
23+
}
24+
}
25+
26+
// ServiceStatus represents the status of a service
27+
type ServiceStatus struct {
28+
Name string `json:"name"`
29+
Enabled bool `json:"enabled"`
30+
Online bool `json:"online"`
31+
Error string `json:"error,omitempty"`
32+
Latency string `json:"latency,omitempty"`
33+
}
34+
35+
// ServiceStatusResponse represents the response for service status check
36+
type ServiceStatusResponse struct {
37+
Services []ServiceStatus `json:"services"`
38+
}
39+
40+
// CheckStatus handles GET /api/system/services
41+
func (h *ServiceStatusHandler) CheckStatus(w http.ResponseWriter, r *http.Request) {
42+
// Always get fresh config to reflect current settings
43+
cfg := config.Get()
44+
45+
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
46+
defer cancel()
47+
48+
var wg sync.WaitGroup
49+
results := make([]ServiceStatus, 0)
50+
resultsChan := make(chan ServiceStatus, 5)
51+
52+
// Helper to check service
53+
checkService := func(name string, enabled bool, pinger func(context.Context) error) {
54+
defer wg.Done()
55+
status := ServiceStatus{
56+
Name: name,
57+
Enabled: enabled,
58+
}
59+
60+
if !enabled {
61+
resultsChan <- status
62+
return
63+
}
64+
65+
start := time.Now()
66+
if err := pinger(ctx); err != nil {
67+
status.Online = false
68+
status.Error = err.Error()
69+
} else {
70+
status.Online = true
71+
status.Latency = time.Since(start).String()
72+
}
73+
resultsChan <- status
74+
}
75+
76+
// Jellyfin
77+
wg.Add(1)
78+
go func() {
79+
client := clients.NewJellyfinClient(cfg.Integrations.Jellyfin)
80+
checkService("Jellyfin", cfg.Integrations.Jellyfin.Enabled, client.Ping)
81+
}()
82+
83+
// Radarr
84+
wg.Add(1)
85+
go func() {
86+
client := clients.NewRadarrClient(cfg.Integrations.Radarr)
87+
checkService("Radarr", cfg.Integrations.Radarr.Enabled, client.Ping)
88+
}()
89+
90+
// Sonarr
91+
wg.Add(1)
92+
go func() {
93+
client := clients.NewSonarrClient(cfg.Integrations.Sonarr)
94+
checkService("Sonarr", cfg.Integrations.Sonarr.Enabled, client.Ping)
95+
}()
96+
97+
// Jellyseerr
98+
wg.Add(1)
99+
go func() {
100+
client := clients.NewJellyseerrClient(cfg.Integrations.Jellyseerr)
101+
checkService("Jellyseerr", cfg.Integrations.Jellyseerr.Enabled, client.Ping)
102+
}()
103+
104+
// Jellystat
105+
wg.Add(1)
106+
go func() {
107+
client := clients.NewJellystatClient(cfg.Integrations.Jellystat)
108+
checkService("Jellystat", cfg.Integrations.Jellystat.Enabled, client.Ping)
109+
}()
110+
111+
// Wait for all checks to complete
112+
go func() {
113+
wg.Wait()
114+
close(resultsChan)
115+
}()
116+
117+
// Collect results
118+
for status := range resultsChan {
119+
results = append(results, status)
120+
}
121+
122+
w.Header().Set("Content-Type", "application/json")
123+
json.NewEncoder(w).Encode(ServiceStatusResponse{Services: results})
124+
}

internal/api/router.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/go-chi/cors"
1010
"github.com/ramonskie/oxicleanarr/internal/api/handlers"
1111
mw "github.com/ramonskie/oxicleanarr/internal/api/middleware"
12+
"github.com/ramonskie/oxicleanarr/internal/config"
1213
"github.com/ramonskie/oxicleanarr/internal/services"
1314
"github.com/ramonskie/oxicleanarr/internal/storage"
1415
)
@@ -52,6 +53,7 @@ func NewRouter(deps *RouterDependencies) *chi.Mux {
5253
configHandler := handlers.NewConfigHandler(deps.SyncEngine)
5354
rulesHandler := handlers.NewRulesHandler()
5455
systemHandler := handlers.NewSystemHandler(deps.SyncEngine, deps.ShutdownCh)
56+
servicesHandler := handlers.NewServiceStatusHandler(config.Get())
5557

5658
// Public routes
5759
r.Get("/health", healthHandler.Handle)
@@ -107,6 +109,7 @@ func NewRouter(deps *RouterDependencies) *chi.Mux {
107109
r.Post("/system/restart", systemHandler.Restart)
108110
r.Get("/system/health", systemHandler.HealthCheck)
109111
r.Get("/system/info", systemHandler.GetInfo)
112+
r.Get("/system/services", servicesHandler.CheckStatus)
110113
})
111114
})
112115

web/src/App.tsx

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { Toaster } from 'sonner';
55
import { useAuthStore } from '@/store/auth';
6-
import { useThemeStore } from '@/store/theme';
6+
import ErrorBoundary from '@/components/ErrorBoundary';
7+
import { errorLogger } from '@/lib/error-logger';
78
import LoginPage from '@/pages/LoginPage';
89
import DashboardPage from '@/pages/DashboardPage';
910
import TimelinePage from '@/pages/TimelinePage';
@@ -17,26 +18,50 @@ import ProtectedRoute from '@/components/ProtectedRoute';
1718
const queryClient = new QueryClient({
1819
defaultOptions: {
1920
queries: {
20-
retry: 1,
21+
retry: (failureCount, error: any) => {
22+
// Don't retry on 401/403 (auth errors)
23+
if (error?.response?.status === 401 || error?.response?.status === 403) {
24+
return false;
25+
}
26+
// Don't retry on 404 (not found)
27+
if (error?.response?.status === 404) {
28+
return false;
29+
}
30+
// Retry up to 2 times for other errors (network issues, 500s, etc.)
31+
return failureCount < 2;
32+
},
33+
retryDelay: (attemptIndex) => {
34+
// Exponential backoff: 1s, 2s, 4s
35+
return Math.min(1000 * 2 ** attemptIndex, 30000);
36+
},
2137
refetchOnWindowFocus: true,
2238
staleTime: 30000, // Consider data stale after 30 seconds
39+
gcTime: 300000, // Keep unused data in cache for 5 minutes
40+
},
41+
mutations: {
42+
retry: false, // Don't retry mutations by default
43+
onError: (error: any) => {
44+
// Log mutation errors
45+
errorLogger.error('React Query mutation failed', error, {
46+
type: 'mutation',
47+
});
48+
},
2349
},
2450
},
2551
});
2652

2753
function App() {
2854
const initializeAuth = useAuthStore((state) => state.initialize);
29-
const initializeTheme = useThemeStore((state) => state.initialize);
3055

3156
useEffect(() => {
3257
initializeAuth();
33-
initializeTheme();
34-
}, [initializeAuth, initializeTheme]);
58+
}, [initializeAuth]);
3559

3660
return (
37-
<QueryClientProvider client={queryClient}>
38-
<BrowserRouter>
39-
<Routes>
61+
<ErrorBoundary>
62+
<QueryClientProvider client={queryClient}>
63+
<BrowserRouter>
64+
<Routes>
4065
<Route path="/login" element={<LoginPage />} />
4166
<Route
4267
path="/"
@@ -80,6 +105,10 @@ function App() {
80105
/>
81106
<Route
82107
path="/configuration"
108+
element={<Navigate to="/settings/general" replace />}
109+
/>
110+
<Route
111+
path="/settings/:section"
83112
element={
84113
<ProtectedRoute>
85114
<ConfigurationPage />
@@ -95,10 +124,11 @@ function App() {
95124
}
96125
/>
97126
<Route path="*" element={<Navigate to="/" replace />} />
98-
</Routes>
99-
</BrowserRouter>
100-
<Toaster position="top-right" richColors />
101-
</QueryClientProvider>
127+
</Routes>
128+
</BrowserRouter>
129+
<Toaster position="top-right" richColors />
130+
</QueryClientProvider>
131+
</ErrorBoundary>
102132
);
103133
}
104134

web/src/components/AppHeader.tsx

Lines changed: 0 additions & 125 deletions
This file was deleted.

0 commit comments

Comments
 (0)