- Implemented new CSS animations for UI elements including bobbing, pulsing, rotating, and spinning effects. - Integrated loading overlays in CrowdSecConfig, Login, ProxyHosts, Security, and WafConfig pages to enhance user experience during asynchronous operations. - Added contextual messages for loading states to inform users about ongoing processes. - Created tests for Login and Security pages to ensure overlays function correctly during login attempts and security operations.
39 KiB
📋 Plan: Thematic Loading Overlays (Charon, Coin, & Cerberus)
🧐 UX & Context Analysis
Problem: When users make configuration changes (create/update/delete proxy hosts, security configs, certificates), Charon applies the new config to Caddy via its admin API. During this reload process (which can take 1-3 seconds, and up to 5-10 seconds with WAF/security features), the Caddy admin API temporarily stops responding on port 2019. Currently, users receive no visual feedback that a reload is happening, and they can attempt to make additional changes before the previous reload completes.
Desired User Flow:
- User submits a configuration change (create/update/delete proxy host, security config, etc.)
- NEW: Thematic loading overlay appears:
- Coin Theme (Gold/Spinning Obol): Authentication/Login - "Paying the ferryman"
- Charon Theme (Blue/Boat): Proxy hosts, certificates, general config - "Ferrying across the Styx"
- Cerberus Theme (Red/Guardian): WAF, CrowdSec, ACL, Rate Limiting - "Guardian stands watch"
- Backend applies config to Caddy (admin API may restart during this process)
- Backend returns success/failure response
- NEW: Loading overlay disappears
- User sees success toast and updated data
- User can safely make additional changes
Why This Matters:
- Prevents race conditions from rapid sequential changes
- Provides clear feedback during potentially slow operations (WAF config reloads can take 5-10s)
- Prevents user confusion when admin API is temporarily unavailable
- Reinforces Branding: Complete Greek mythology theme (Charon the ferryman, Cerberus the guardian, obol coin)
- Visual Distinction: Three clear themes - Auth (gold), Proxy (blue), Security (red)
- Perfect Metaphor: Login = paying Charon for passage into the Underworld (app)
- Matches enterprise-grade UX expectations with personality
🤝 Handoff Contract (The Truth)
Backend Changes: NONE REQUIRED
Backend already handles config reloads correctly and returns appropriate HTTP status codes. The backend sequence is:
- Save changes to database
- Call
caddyManager.ApplyConfig(ctx) - Return success (200/201) or error (400/500)
- If error, rollback database changes
No backend modifications needed - this is a frontend-only UX enhancement.
Frontend API Response Structure (Existing)
// POST /api/v1/proxy-hosts (success)
{
"uuid": "abc-123",
"name": "My Service",
"domain_names": "example.com",
"enabled": true,
"created_at": "2025-12-04T10:00:00Z",
"updated_at": "2025-12-04T10:00:00Z"
}
// Error response (if Caddy reload fails)
{
"error": "Failed to apply configuration: connection refused"
}
🎨 Phase 1: Frontend Implementation (React)
1.1 Create Thematic Loading Animations
File: frontend/src/components/LoadingStates.tsx
A. Charon-Themed Loader (Proxy/General Operations)
New Component: CharonLoader - Boat on Waves animation (Charon ferrying across the Styx)
export function CharonLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-12 h-12',
md: 'w-20 h-20',
lg: 'w-28 h-28',
}
return (
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Loading">
{/* Animated waves */}
<svg className="w-full h-full absolute inset-0" viewBox="0 0 100 100">
{/* Top wave */}
<path
d="M0,50 Q25,45 50,50 T100,50"
stroke="currentColor"
className="text-blue-400/40"
fill="none"
strokeWidth="2"
strokeLinecap="round"
>
<animate
attributeName="d"
values="M0,50 Q25,45 50,50 T100,50;
M0,50 Q25,55 50,50 T100,50;
M0,50 Q25,45 50,50 T100,50"
dur="2s"
repeatCount="indefinite"
/>
</path>
{/* Bottom wave (delayed) */}
<path
d="M0,60 Q25,55 50,60 T100,60"
stroke="currentColor"
className="text-blue-500/30"
fill="none"
strokeWidth="2"
strokeLinecap="round"
>
<animate
attributeName="d"
values="M0,60 Q25,55 50,60 T100,60;
M0,60 Q25,65 50,60 T100,60;
M0,60 Q25,55 50,60 T100,60"
dur="2s"
begin="0.3s"
repeatCount="indefinite"
/>
</path>
</svg>
{/* Boat silhouette (bobbing) */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="animate-bob-boat">
{/* Simple boat shape */}
<svg width="32" height="24" viewBox="0 0 32 24" fill="none">
<path
d="M4,16 L8,8 L24,8 L28,16 L26,20 L6,20 Z"
fill="currentColor"
className="text-slate-600"
/>
<path
d="M8,8 L16,4 L24,8"
stroke="currentColor"
className="text-slate-700"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</div>
</div>
</div>
)
}
Tailwind Config Addition (or add to global CSS):
@keyframes bob-boat {
0%, 100% { transform: translateY(-3px); }
50% { transform: translateY(3px); }
}
.animate-bob-boat {
animation: bob-boat 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.6; transform: scale(1); }
50% { opacity: 1; transform: scale(1.05); }
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes rotate-head {
0%, 100% { transform: rotate(-10deg); }
50% { transform: rotate(10deg); }
}
.animate-rotate-head {
animation: rotate-head 3s ease-in-out infinite;
}
B. Charon Coin Loader (Authentication/Login)
New Component: CharonCoinLoader - Spinning Obol Coin animation (Payment to the Ferryman)
export function CharonCoinLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-12 h-12',
md: 'w-20 h-20',
lg: 'w-28 h-28',
}
return (
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Authenticating">
{/* Coin spinning on Y-axis */}
<svg className="w-full h-full absolute inset-0" viewBox="0 0 100 100">
{/* Coin face (animated perspective) */}
<ellipse
cx="50"
cy="50"
rx="30"
ry="30"
fill="currentColor"
className="text-amber-600"
>
<animate
attributeName="rx"
values="30;5;30"
dur="2s"
repeatCount="indefinite"
/>
</ellipse>
{/* Coin edge (visible during flip) */}
<rect
x="45"
y="20"
width="10"
height="60"
fill="currentColor"
className="text-amber-800"
rx="2"
>
<animate
attributeName="width"
values="10;0;10"
dur="2s"
repeatCount="indefinite"
/>
<animate
attributeName="x"
values="45;50;45"
dur="2s"
repeatCount="indefinite"
/>
</rect>
{/* Coin detail lines (Charon's mark) */}
<g opacity="0.7">
<line x1="40" y1="45" x2="60" y2="45" stroke="currentColor" className="text-amber-900" strokeWidth="2">
<animate
attributeName="opacity"
values="0.7;0;0.7"
dur="2s"
repeatCount="indefinite"
/>
</line>
<line x1="40" y1="50" x2="60" y2="50" stroke="currentColor" className="text-amber-900" strokeWidth="2">
<animate
attributeName="opacity"
values="0.7;0;0.7"
dur="2s"
repeatCount="indefinite"
/>
</line>
<line x1="40" y1="55" x2="60" y2="55" stroke="currentColor" className="text-amber-900" strokeWidth="2">
<animate
attributeName="opacity"
values="0.7;0;0.7"
dur="2s"
repeatCount="indefinite"
/>
</line>
</g>
{/* Subtle shine effect */}
<ellipse
cx="55"
cy="40"
rx="8"
ry="12"
fill="currentColor"
className="text-yellow-400/40"
>
<animate
attributeName="opacity"
values="0.4;0.7;0.4"
dur="2s"
repeatCount="indefinite"
/>
</ellipse>
</svg>
</div>
)
}
Why Coin for Authentication:
- Mythology Perfect: In Greek mythology, the dead paid Charon with an obol (coin) to cross the River Styx
- Metaphor: User is "paying for passage" into the application
- Visual Interest: Spinning coin on Y-axis creates engaging 3D effect
- Distinct From Other Operations: Gold/amber vs blue (proxy) or red (security)
C. Cerberus-Themed Loader (Security Operations)
New Component: CerberusLoader - Three-Headed Guardian animation
export function CerberusLoader({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizeClasses = {
sm: 'w-12 h-12',
md: 'w-20 h-20',
lg: 'w-28 h-28',
}
return (
<div className={`${sizeClasses[size]} relative`} role="status" aria-label="Security Loading">
{/* Central body with pulsing shield */}
<svg className="w-full h-full absolute inset-0" viewBox="0 0 100 100">
{/* Shield background (pulsing) */}
<path
d="M50,10 L70,20 L70,45 Q70,65 50,75 Q30,65 30,45 L30,20 Z"
fill="currentColor"
className="text-red-900/30"
>
<animate
attributeName="opacity"
values="0.3;0.6;0.3"
dur="2s"
repeatCount="indefinite"
/>
</path>
{/* Shield outline */}
<path
d="M50,10 L70,20 L70,45 Q70,65 50,75 Q30,65 30,45 L30,20 Z"
stroke="currentColor"
className="text-red-500"
fill="none"
strokeWidth="2"
/>
{/* Left head (animated rotation) */}
<circle cx="35" cy="30" r="6" fill="currentColor" className="text-red-600">
<animate
attributeName="cy"
values="30;28;30"
dur="2s"
repeatCount="indefinite"
/>
</circle>
{/* Center head (larger, animated) */}
<circle cx="50" cy="35" r="7" fill="currentColor" className="text-red-500">
<animate
attributeName="r"
values="7;8;7"
dur="2s"
repeatCount="indefinite"
/>
</circle>
{/* Right head (animated rotation) */}
<circle cx="65" cy="30" r="6" fill="currentColor" className="text-red-600">
<animate
attributeName="cy"
values="30;28;30"
dur="2s"
begin="1s"
repeatCount="indefinite"
/>
</circle>
{/* Eyes (glowing effect) */}
<circle cx="33" cy="29" r="1.5" fill="currentColor" className="text-yellow-300">
<animate
attributeName="opacity"
values="1;0.3;1"
dur="3s"
repeatCount="indefinite"
/>
</circle>
<circle cx="37" cy="29" r="1.5" fill="currentColor" className="text-yellow-300">
<animate
attributeName="opacity"
values="1;0.3;1"
dur="3s"
repeatCount="indefinite"
/>
</circle>
<circle cx="48" cy="34" r="1.5" fill="currentColor" className="text-yellow-300">
<animate
attributeName="opacity"
values="1;0.3;1"
dur="3s"
begin="0.5s"
repeatCount="indefinite"
/>
</circle>
<circle cx="52" cy="34" r="1.5" fill="currentColor" className="text-yellow-300">
<animate
attributeName="opacity"
values="1;0.3;1"
dur="3s"
begin="0.5s"
repeatCount="indefinite"
/>
</circle>
<circle cx="63" cy="29" r="1.5" fill="currentColor" className="text-yellow-300">
<animate
attributeName="opacity"
values="1;0.3;1"
dur="3s"
begin="1s"
repeatCount="indefinite"
/>
</circle>
<circle cx="67" cy="29" r="1.5" fill="currentColor" className="text-yellow-300">
<animate
attributeName="opacity"
values="1;0.3;1"
dur="3s"
begin="1s"
repeatCount="indefinite"
/>
</circle>
</svg>
</div>
)
}
Enhancement: Add overlay components with appropriate theming:
export function ConfigReloadOverlay({
message = 'Ferrying configuration...',
submessage = 'Charon is crossing the Styx',
type = 'charon'
}: {
message?: string
submessage?: string
type?: 'charon' | 'coin' | 'cerberus'
}) {
const Loader =
type === 'cerberus' ? CerberusLoader :
type === 'coin' ? CharonCoinLoader :
CharonLoader
const bgColor =
type === 'cerberus' ? 'bg-red-950/90' :
type === 'coin' ? 'bg-amber-950/90' :
'bg-slate-800'
const borderColor =
type === 'cerberus' ? 'border-red-900/50' :
type === 'coin' ? 'border-amber-900/50' :
'border-slate-700'
return (
<div className="fixed inset-0 bg-slate-900/70 backdrop-blur-sm flex items-center justify-center z-50">
<div className={`${bgColor} ${borderColor} border rounded-lg p-8 flex flex-col items-center gap-6 shadow-xl max-w-md`}>
<Loader size="lg" />
<div className="text-center">
<p className="text-slate-200 font-medium text-lg">{message}</p>
<p className="text-slate-400 text-sm mt-2">{submessage}</p>
</div>
</div>
</div>
)
}
Why Cerberus Theme:
- Mythology Match: Cerberus is the three-headed guard dog of the Underworld gates - perfect for security operations
- Charon Connection: Both from Greek mythology, thematically consistent with app branding
- Visual Distinction: Red/shield theme vs blue/boat clearly differentiates security vs general operations
- Three Heads = Three Layers: WAF, CrowdSec, Rate Limiting (the three security components)
- Guardian Symbolism: Emphasizes protective nature of security features
Why Coin Theme for Login:
- Perfect Mythology: In Greek myth, souls paid Charon an obol (coin) to cross into the Underworld
- Natural Metaphor: User "pays for passage" to access the application
- Thematic Consistency: Login = entering the realm, coin = the required payment
- Visual Appeal: 3D spinning coin effect is engaging and distinct
- Color Distinction: Gold/amber distinguishes auth from proxy (blue) and security (red)
Future Enhancement (separate issue): Implement hybrid approach with rotating animations for all three themes:
- Charon: Boat (current), Rowing Oar, River Flow
- Coin/Auth: Coin Flip (current), Coin Drop, Token Glow, Gate Opening
- Cerberus: Three Heads (current), Shield Pulse, Guardian Stance, Chain Links
1.2 Update Hook to Expose Mutation States
File: frontend/src/hooks/useProxyHosts.ts
Change: Already exposes isCreating, isUpdating, isDeleting, isBulkUpdating - NO CHANGES NEEDED.
1.3 Add Loading Overlay to UI Pages
Files to Modify:
Charon Theme (Blue/Boat):
frontend/src/pages/ProxyHosts.tsx- Proxy host CRUDfrontend/src/components/ProxyHostForm.tsx- Form mutationsfrontend/src/components/CertificateList.tsx- Certificate operations
Coin Theme (Gold/Amber):
frontend/src/pages/Login.tsx- Login authenticationfrontend/src/context/AuthContext.tsx- Initial auth check (optional)
Cerberus Theme (Red/Guardian):
frontend/src/pages/WafConfig.tsx- WAF ruleset operationsfrontend/src/pages/Security.tsx- Security toggle operationsfrontend/src/pages/CrowdSecConfig.tsx- CrowdSec configurationfrontend/src/pages/AccessLists.tsx- ACL operations (when implementing rate limiting page)
Implementation Pattern (ProxyHosts.tsx example - Charon Theme):
import { ConfigReloadOverlay } from '../components/LoadingStates'
export default function ProxyHosts() {
const {
hosts,
loading,
isCreating,
isUpdating,
isDeleting,
isBulkUpdating
} = useProxyHosts()
// Show overlay when ANY mutation is in progress
const isApplyingConfig = isCreating || isUpdating || isDeleting || isBulkUpdating
// Determine contextual message based on operation
const getMessage = () => {
if (isCreating) return {
message: "Ferrying new host...",
submessage: "Charon is crossing the Styx"
}
if (isDeleting) return {
message: "Returning to shore...",
submessage: "Host departure in progress"
}
if (isBulkUpdating) return {
message: "Ferrying souls...",
submessage: "Bulk operation crossing the river"
}
return {
message: "Guiding changes across...",
submessage: "Configuration in transit"
}
}
const { message, submessage } = getMessage()
return (
<>
{isApplyingConfig && (
<ConfigReloadOverlay
type="charon"
message={message}
submessage={submessage}
/>
)}
{/* Existing page content */}
<div className="space-y-6">
{/* ... existing code ... */}
</div>
</>
)
}
Implementation Pattern (Login.tsx example - Coin Theme):
import { ConfigReloadOverlay } from '../components/LoadingStates'
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
await client.post('/auth/login', { email, password })
await login()
toast.success('Welcome aboard')
navigate('/')
} catch (err) {
toast.error('Invalid credentials')
} finally {
setLoading(false)
}
}
return (
<>
{loading && (
<ConfigReloadOverlay
type="coin"
message="Paying the ferryman..."
submessage="Your obol grants passage"
/>
)}
<div className="min-h-screen bg-dark-bg flex items-center justify-center">
<Card>
<form onSubmit={handleSubmit}>
{/* form fields */}
<Button type="submit" disabled={loading}>
Sign In
</Button>
</form>
</Card>
</div>
</>
)
}
Implementation Pattern (WafConfig.tsx example - Cerberus Theme):
import { ConfigReloadOverlay } from '../components/LoadingStates'
export default function WafConfig() {
const { data: ruleSets, isLoading, error } = useRuleSets()
const upsertMutation = useUpsertRuleSet()
const deleteMutation = useDeleteRuleSet()
// Determine if any security operation is in progress
const isApplyingConfig = upsertMutation.isPending || deleteMutation.isPending
// Determine contextual message based on operation
const getMessage = () => {
if (upsertMutation.isPending) return {
message: "Forging new defenses...",
submessage: "Cerberus strengthens the ward"
}
if (deleteMutation.isPending) return {
message: "Lowering a barrier...",
submessage: "Defense layer removed"
}
return {
message: "Cerberus awakens...",
submessage: "Guardian stands watch"
}
}
const { message, submessage } = getMessage()
return (
<>
{isApplyingConfig && (
<ConfigReloadOverlay
type="cerberus"
message={message}
submessage={submessage}
/>
)}
{/* Existing page content */}
<div className="space-y-6">
{/* ... existing code ... */}
</div>
</>
)
}
Custom Messages per Operation:
Charon Theme (Proxy/General Operations):
- Create:
"Ferrying new host..."/"Charon is crossing the Styx" - Update:
"Guiding changes across..."/"Configuration in transit" - Delete:
"Returning to shore..."/"Host departure in progress" - Bulk Update:
"Ferrying {count} souls..."/"Bulk operation crossing the river"
Coin Theme (Authentication):
- Login:
"Paying the ferryman..."/"Your obol grants passage" - Initial Load:
"The coin spins..."/"Seeking Charon's favor" - Session Check:
"Verifying payment..."/"Charon examines the coin"
Cerberus Theme (Security Operations):
- WAF Config:
"Cerberus awakens..."/"Guardian of the gates stands watch" - WAF Enable/Disable:
"Three heads turn..."/"Web Application Firewall ${enabled ? 'rising' : 'resting'}" - Security Config:
"Strengthening the guard..."/"Protective wards activating" - CrowdSec Enable:
"Summoning the guardian..."/"Intrusion prevention rising" - Rate Limit Enable:
"Chains rattle..."/"Traffic gates engaging" - ACL Update:
"Guarding the threshold..."/"Access barriers shifting" - Ruleset Create/Update:
"Forging new defenses..."/"Security rules inscribing" - Ruleset Delete:
"Lowering a barrier..."/"Defense layer removed"
1.4 Disable Form Inputs During Mutations
File: frontend/src/components/ProxyHostForm.tsx
Enhancement: Disable all form inputs when parent is applying config:
interface ProxyHostFormProps {
// ... existing props
isApplyingConfig?: boolean // NEW
}
export default function ProxyHostForm({
host,
onSave,
onCancel,
isApplyingConfig = false // NEW
}: ProxyHostFormProps) {
// Disable entire form during config reload
const isFormDisabled = isApplyingConfig
return (
<form onSubmit={handleSubmit}>
<input
disabled={isFormDisabled}
// ... other props
/>
<button
disabled={isFormDisabled}
type="submit"
>
{isApplyingConfig ? 'Applying...' : 'Save'}
</button>
</form>
)
}
1.5 Handle Bulk Operations
File: frontend/src/pages/ProxyHosts.tsx
Bulk ACL Update: Already uses isBulkUpdating state - just add overlay:
const handleBulkUpdateACL = async () => {
try {
// Loading overlay automatically shows via isBulkUpdating
// Message: "Ferrying {count} souls..." displays automatically
const result = await bulkUpdateACL(selectedUUIDs, selectedACLID)
toast.success(`Ferried ${result.updated} souls safely across`)
if (result.errors.length > 0) {
toast.error(`${result.errors.length} souls could not cross`)
}
} catch (err) {
toast.error('Ferry crossing failed')
}
}
Bulk Delete: Same pattern with isDeleting state.
🕵️ Phase 2: QA & Edge Cases
Edge Case Testing
| Scenario | Expected Behavior |
|---|---|
| Rapid Sequential Changes | Second change waits for first to complete (overlay remains visible) |
| Config Apply Fails | Overlay disappears, error toast shows, form re-enabled |
| Long WAF Reload (10s) | Overlay remains visible throughout, no timeout |
| Concurrent User Changes | Each user sees their own overlay, React Query handles cache |
| Browser Tab Switch | Overlay persists across tab switches (React state maintained) |
| Form Validation Error | Overlay never appears (validation happens before mutation) |
| Network Timeout | React Query timeout (30s default) triggers error, overlay clears |
| Theme Switching | Coin (gold) for auth, Charon (blue) for proxy, Cerberus (red) for security |
| Login Flow | Coin overlay shows "Paying the ferryman..." during authentication |
| Security Toggle | Cerberus overlay shows when enabling/disabling WAF, CrowdSec, Rate Limit, ACL |
| Ruleset Operations | Cerberus overlay for create/update/delete WAF rulesets |
Testing Checklist
Manual Testing:
Coin Theme (Authentication):
- ✅ Login with valid credentials → Coin (gold) overlay appears → "Paying the ferryman..." → success → dashboard
- ✅ Login with invalid credentials → Coin overlay → error toast → overlay clears
- ✅ App initial load (auth check) → Optional: subtle coin animation during /auth/me call
Charon Theme (Proxy Operations): 4. ✅ Create new proxy host → Charon (blue) overlay appears → success → overlay disappears 5. ✅ Update existing host → Charon overlay during update → success 6. ✅ Delete host → Charon overlay with "Returning to shore..." → success 7. ✅ Bulk update ACL on 5 hosts → Charon overlay with "Ferrying souls..." → success 8. ✅ Certificate upload → Charon overlay → success
Cerberus Theme (Security Operations): 9. ✅ Enable WAF → Cerberus (red) overlay with "Three heads turn..." → success 10. ✅ Create WAF ruleset → Cerberus overlay "Forging new defenses..." → success (5-10s) 11. ✅ Delete WAF ruleset → Cerberus overlay "Lowering a barrier..." → success 12. ✅ Enable CrowdSec → Cerberus overlay "Summoning the guardian..." → success 13. ✅ Update security config → Cerberus overlay "Strengthening the guard..." → success 14. ✅ Enable Rate Limiting → Cerberus overlay "Chains rattle..." → success
General: 15. ✅ Submit invalid data → validation error, NO overlay shown 16. ✅ Trigger Caddy error (stop Caddy) → overlay → error toast → overlay clears 17. ✅ Rapid clicks on save button → first click triggers overlay, subsequent ignored 18. ✅ Navigate away during reload → confirm user intent, abort mutation 19. ✅ Test in Firefox, Chrome, Safari → consistent behavior 20. ✅ Verify theme colors: Coin (gold/amber), Charon (blue boat), Cerberus (red guardian)
Automated Testing:
// frontend/src/pages/__tests__/ProxyHosts-reload-overlay.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProxyHosts from '../ProxyHosts'
it('shows Charon-themed overlay during proxy host create', async () => {
// Mock API to delay response
vi.mocked(proxyHostsApi.createProxyHost).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(mockHost), 2000))
)
render(<ProxyHosts />)
// Click create
await userEvent.click(screen.getByText('Add Proxy Host'))
await userEvent.click(screen.getByText('Save'))
// Charon-themed overlay should appear
expect(screen.getByText('Ferrying new host...')).toBeInTheDocument()
expect(screen.getByText('Charon is crossing the Styx')).toBeInTheDocument()
// Overlay should disappear after completion
await waitFor(() => {
expect(screen.queryByText('Ferrying new host...')).not.toBeInTheDocument()
}, { timeout: 3000 })
})
it('disables form inputs during config reload', async () => {
render(<ProxyHosts />)
const saveButton = screen.getByText('Save')
await userEvent.click(saveButton)
// Button should be disabled during mutation
expect(saveButton).toBeDisabled()
expect(saveButton).toHaveTextContent('Applying...')
})
// frontend/src/pages/__tests__/Login-coin-overlay.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Login from '../Login'
it('shows coin-themed overlay during login', async () => {
// Mock API to delay response
vi.mocked(client.post).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ data: {} }), 2000))
)
render(<Login />)
// Fill form and submit
await userEvent.type(screen.getByLabelText('Email'), 'admin@example.com')
await userEvent.type(screen.getByLabelText('Password'), 'password123')
await userEvent.click(screen.getByText('Sign In'))
// Coin-themed overlay should appear
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
expect(screen.getByText('Your obol grants passage')).toBeInTheDocument()
// Verify gold/amber theme styling
const overlay = screen.getByText('Paying the ferryman...').closest('div')
expect(overlay).toHaveClass('bg-amber-950/90')
// Overlay should disappear after successful login
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
}, { timeout: 3000 })
})
it('clears overlay on login error', async () => {
vi.mocked(client.post).mockRejectedValue({
response: { data: { error: 'Invalid credentials' } }
})
render(<Login />)
await userEvent.type(screen.getByLabelText('Email'), 'wrong@example.com')
await userEvent.type(screen.getByLabelText('Password'), 'wrong')
await userEvent.click(screen.getByText('Sign In'))
// Overlay appears
expect(screen.getByText('Paying the ferryman...')).toBeInTheDocument()
// Overlay clears after error
await waitFor(() => {
expect(screen.queryByText('Paying the ferryman...')).not.toBeInTheDocument()
})
// Error toast shown (tested elsewhere)
})
// frontend/src/pages/__tests__/WafConfig-reload-overlay.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import WafConfig from '../WafConfig'
it('shows Cerberus-themed overlay during ruleset create', async () => {
// Mock API to delay response (WAF operations can be slow)
vi.mocked(securityApi.upsertRuleSet).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(mockRuleSet), 5000))
)
render(<WafConfig />)
// Open create form and submit
await userEvent.click(screen.getByText('Add Rule Set'))
await userEvent.type(screen.getByLabelText('Rule Set Name'), 'Test Rules')
await userEvent.type(screen.getByLabelText('Rule Content'), 'SecRule REQUEST_URI "@contains test"')
await userEvent.click(screen.getByText('Create Rule Set'))
// Cerberus-themed overlay should appear
expect(screen.getByText('Forging new defenses...')).toBeInTheDocument()
expect(screen.getByText('Cerberus strengthens the ward')).toBeInTheDocument()
// Verify red theme styling
const overlay = screen.getByText('Forging new defenses...').closest('div')
expect(overlay).toHaveClass('bg-red-950/90')
// Overlay should disappear after completion
await waitFor(() => {
expect(screen.queryByText('Forging new defenses...')).not.toBeInTheDocument()
}, { timeout: 6000 })
})
it('shows Cerberus overlay for delete operation', async () => {
vi.mocked(securityApi.deleteRuleSet).mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve(), 2000))
)
render(<WafConfig />)
await userEvent.click(screen.getByTestId('delete-ruleset-1'))
await userEvent.click(screen.getByTestId('confirm-delete-btn'))
// Cerberus delete message
expect(screen.getByText('Lowering a barrier...')).toBeInTheDocument()
expect(screen.getByText('Defense layer removed')).toBeInTheDocument()
})
📚 Phase 3: Documentation
User Documentation
File: docs/features.md
Add new section:
## Configuration Feedback
When you make changes to proxy hosts, security settings, or certificates, Charon applies the configuration to Caddy's reverse proxy. During this process:
- 🔄 **Loading Overlay**: A blocking overlay appears with "Applying configuration..."
- ⏱️ **Duration**: Typically 1-3 seconds, up to 10 seconds for complex WAF configurations
- 🚫 **Input Disabled**: Form inputs are disabled during reload to prevent conflicts
- ✅ **Success Feedback**: Toast notification confirms successful application
- ❌ **Error Handling**: If reload fails, the overlay clears and an error message appears
**Note**: Caddy's admin API temporarily restarts during config reloads. This is normal behavior and the UI will wait for completion before allowing new changes.
Developer Documentation
File: frontend/src/components/LoadingStates.tsx (JSDoc comments)
/**
* ConfigReloadOverlay - Full-screen blocking overlay for Caddy configuration reloads
*
* Display when:
* - Creating/updating/deleting proxy hosts
* - Applying WAF or security configurations
* - Bulk operations that trigger Caddy reloads
*
* Technical Notes:
* - Caddy admin API (port 2019) stops during config reloads (1-10s)
* - Overlay uses z-50 to block all interactions
* - Automatically clears when mutation completes/fails
*
* @param message - Primary message (e.g., "Applying configuration...")
* @param submessage - Secondary context (e.g., "Please wait while Caddy reloads")
*/
🛠️ Implementation Checklist
Step 1: Create Components (45 min)
- Add
CharonLoader(boat) toLoadingStates.tsx - Add
CharonCoinLoader(spinning obol) toLoadingStates.tsx - Add
CerberusLoader(three heads) toLoadingStates.tsx - Add
ConfigReloadOverlaywith theme support - Add Tailwind keyframes for all animations
- Add unit tests for new components
- Verify styling in dev environment
Step 2: Update Login Page (20 min)
- Import
ConfigReloadOverlaywith coin theme - Replace button
isLoadingstate with full overlay - Add "Paying the ferryman..." message
- Test login flow with overlay
- Verify coin animation performance
Step 3: Update ProxyHosts Page (45 min)
- Import
ConfigReloadOverlaywith Charon theme - Add
isApplyingConfigcomputed state - Render overlay conditionally
- Test create/update/delete operations
- Test bulk operations
Step 4: Update Security Pages (30 min each)
- Update
CrowdSecConfig.tsx(Cerberus theme) - Update
WAFConfig.tsx(Cerberus theme) - Update
Security.tsxfor toggle operations (Cerberus theme) - Test with actual WAF ruleset uploads (slow path)
- Test security toggle operations (enable/disable services)
Step 5: Update Certificate Management (20 min)
- Update
CertificateList.tsx(Charon theme) - Test certificate upload/delete
Step 6: Update Form Component (30 min)
- Add
isApplyingConfigprop toProxyHostForm - Disable all inputs when true
- Update button text during mutation
- Test in modal and standalone contexts
Step 7: Write Tests (75 min)
- Component tests for all three loaders (Charon, Coin, Cerberus)
- Component tests for
ConfigReloadOverlaytheme switching - Integration tests for Login page (coin theme)
- Integration tests for ProxyHosts page (Charon theme)
- Integration tests for WafConfig page (Cerberus theme)
- Test rapid sequential operations
- Test error cases
Step 8: Manual QA (40 min)
- Test login flow with coin animation
- Test all CRUD operations on proxy hosts (Charon)
- Test security operations (Cerberus)
- Test bulk operations
- Test with slow Caddy reloads (add artificial delay)
- Test cross-browser (Chrome, Firefox)
- Verify theme colors: Coin (gold), Charon (blue), Cerberus (red)
Step 9: Documentation (15 min)
- Update
docs/features.md - Add JSDoc comments
- Update CHANGELOG
Total Estimated Time: 6-7 hours (includes Charon, Coin, and Cerberus themes)
✅ Acceptance Criteria
- Loading overlay appears immediately when config mutation starts
- Overlay blocks all UI interactions during reload
- Overlay shows contextual messages per operation type
- Form inputs are disabled during mutations
- Overlay automatically clears on success or error
- No race conditions from rapid sequential changes
- Works consistently in Firefox, Chrome, Safari
- Existing functionality unchanged (no regressions)
- All tests pass (existing + new)
- Pre-commit checks pass
- Correct theme used: Coin (gold) for auth, Charon (blue) for proxy, Cerberus (red) for security
- Login page uses coin theme with "Paying the ferryman..." message
- All security operations (WAF, CrowdSec, ACL, Rate Limit) use Cerberus theme
- Animation performance acceptable (no janky SVG rendering, smooth 60fps)
🔍 Technical Notes
Why Frontend-Only?
The backend already handles config reloads correctly:
- Backend receives request
- Saves to database
- Calls
caddyManager.ApplyConfig() - Returns success/error
- Rolls back DB changes on error
The issue is purely UX - users don't see that a reload is happening and the admin API is temporarily unavailable.
React Query Benefits
We use React Query for state management, which provides:
- Automatic loading states (
isPending) - Error handling
- Cache invalidation
- Retry logic
- Request deduplication
No additional state management needed - we just surface the existing mutation states to the UI.
Z-Index Layering
z-10: Navigation
z-20: Modals
z-30: Tooltips
z-40: Toast notifications
z-50: Config reload overlay (NEW - must block everything)
Performance Impact
Negligible:
- Overlay is conditionally rendered (not always in DOM)
- No polling or long-running timers
- React Query handles all async logic
- Single boolean state check per render
🚫 Out of Scope
The following are explicitly NOT included in this plan:
- Progress Bar: We don't know total reload time in advance
- Cancel Operation: Once submitted to backend, rollback is complex
- Optimistic Updates: Config changes must succeed before showing
- Background Reloads: Config changes MUST complete before new ones start
- Admin API Monitoring: We rely on backend response, not admin API polling
- Retry Logic: React Query provides this, no custom implementation
- Queue System: Not needed - mutations are already sequential per user
📊 Success Metrics
Before (Current):
- Users confused why subsequent changes fail
- Support tickets: "Config changes not working"
- Rapid-fire changes cause race conditions
After (Target):
- Clear visual feedback during reloads
- Zero race conditions from rapid changes
- Reduced support tickets
- Professional UX matching enterprise tools
🔗 Related Issues
- WAF Integration Test Reliability (Issue with Caddy admin API stopping during reload)
- User reported: "Changes don't seem to save" (actually timing issue)
- Enhancement request: Loading indicators for long operations
- Future Enhancement: Hybrid rotating loading animations - see GitHub Issue (to be created)
- Charon Variants: Boat on Waves, Coin Flip, Rowing Oar, River Flow
- Cerberus Variants: Three Heads Alert, Shield Pulse, Guardian Stance, Chain Links
- Randomized selection on each load for visual variety
- Matching thematic messages for each animation variant
📅 Timeline
Day 1 (6 hours):
- Morning: Create all three loader components: Charon, Coin, Cerberus (2.5 hours)
- Afternoon: Update Login page with Coin theme (30 min)
- Afternoon: Update ProxyHosts page with Charon theme (1.5 hours)
- Afternoon: Update WAF/Security pages with Cerberus theme (1.5 hours)
Day 2 (3 hours):
- Morning: Certificate management, CrowdSec config (1 hour)
- Morning: Write unit tests for all three themes (1 hour)
- Afternoon: Manual QA, documentation, code review (1 hour)
Total: 2 days for full tri-theme implementation and testing