Files
Charon/docs/plans/current_spec.md
GitHub Actions 3e4323155f feat: add loading overlays and animations across various pages
- 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.
2025-12-04 15:10:02 +00:00

1170 lines
39 KiB
Markdown

# 📋 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**:
1. User submits a configuration change (create/update/delete proxy host, security config, etc.)
2. **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"
3. Backend applies config to Caddy (admin API may restart during this process)
4. Backend returns success/failure response
5. **NEW**: Loading overlay disappears
6. User sees success toast and updated data
7. 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:
1. Save changes to database
2. Call `caddyManager.ApplyConfig(ctx)`
3. Return success (200/201) or error (400/500)
4. If error, rollback database changes
No backend modifications needed - this is a **frontend-only UX enhancement**.
### Frontend API Response Structure (Existing)
```json
// 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)
```tsx
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):
```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)
```tsx
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
```tsx
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:
```tsx
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 CRUD
- `frontend/src/components/ProxyHostForm.tsx` - Form mutations
- `frontend/src/components/CertificateList.tsx` - Certificate operations
**Coin Theme** (Gold/Amber):
- `frontend/src/pages/Login.tsx` - Login authentication
- `frontend/src/context/AuthContext.tsx` - Initial auth check (optional)
**Cerberus Theme** (Red/Guardian):
- `frontend/src/pages/WafConfig.tsx` - WAF ruleset operations
- `frontend/src/pages/Security.tsx` - Security toggle operations
- `frontend/src/pages/CrowdSecConfig.tsx` - CrowdSec configuration
- `frontend/src/pages/AccessLists.tsx` - ACL operations (when implementing rate limiting page)
**Implementation Pattern** (ProxyHosts.tsx example - Charon Theme):
```tsx
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):
```tsx
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):
```tsx
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:
```tsx
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:
```tsx
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)**:
1. ✅ Login with valid credentials → Coin (gold) overlay appears → "Paying the ferryman..." → success → dashboard
2. ✅ Login with invalid credentials → Coin overlay → error toast → overlay clears
3. ✅ 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**:
```tsx
// 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...')
})
```
```tsx
// 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)
})
```
```tsx
// 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:
```markdown
## 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)
```tsx
/**
* 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) to `LoadingStates.tsx`
- [ ] Add `CharonCoinLoader` (spinning obol) to `LoadingStates.tsx`
- [ ] Add `CerberusLoader` (three heads) to `LoadingStates.tsx`
- [ ] Add `ConfigReloadOverlay` with 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 `ConfigReloadOverlay` with coin theme
- [ ] Replace button `isLoading` state 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 `ConfigReloadOverlay` with Charon theme
- [ ] Add `isApplyingConfig` computed 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.tsx` for 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 `isApplyingConfig` prop to `ProxyHostForm`
- [ ] 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 `ConfigReloadOverlay` theme 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:
1. Backend receives request
2. Saves to database
3. Calls `caddyManager.ApplyConfig()`
4. Returns success/error
5. 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:
1. **Progress Bar**: We don't know total reload time in advance
2. **Cancel Operation**: Once submitted to backend, rollback is complex
3. **Optimistic Updates**: Config changes must succeed before showing
4. **Background Reloads**: Config changes MUST complete before new ones start
5. **Admin API Monitoring**: We rely on backend response, not admin API polling
6. **Retry Logic**: React Query provides this, no custom implementation
7. **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