feature/extend-people-search-and-fix-port-binding #34

Merged
tanyar09 merged 3 commits from feature/extend-people-search-and-fix-port-binding into dev 2026-02-05 12:51:36 -05:00
7 changed files with 465 additions and 0 deletions
Showing only changes of commit d0dd5c82ea - Show all commits

View File

@ -58,6 +58,22 @@ All logs are in: `/home/appuser/.pm2/logs/`
- **Admin**: `punimtag-admin-error.log`, `punimtag-admin-out.log`
- **Viewer**: `punimtag-viewer-error.log`, `punimtag-viewer-out.log`
### Click Logs (Admin Frontend)
Click logs are in: `/opt/punimtag/logs/`
- **Click Log**: `admin-clicks.log` (auto-rotates at 10MB, keeps 5 backups)
- **View live clicks**: `tail -f /opt/punimtag/logs/admin-clicks.log`
- **View recent clicks**: `tail -n 100 /opt/punimtag/logs/admin-clicks.log`
- **Search clicks**: `grep "username\|page" /opt/punimtag/logs/admin-clicks.log`
- **Cleanup old logs**: `./scripts/cleanup-click-logs.sh`
**Automated Cleanup (Crontab):**
```bash
# Add to crontab: cleanup logs weekly (Sundays at 2 AM)
0 2 * * 0 /opt/punimtag/scripts/cleanup-click-logs.sh
```
## 🔧 Direct Log Access
```bash
@ -113,3 +129,15 @@ This configures:
pm2 flush # Clear all logs (be careful!)
```
5. **Viewing click logs?**
```bash
# Watch clicks in real-time
tail -f /opt/punimtag/logs/admin-clicks.log
# View recent clicks
tail -n 100 /opt/punimtag/logs/admin-clicks.log
# Search for specific user or page
grep "admin\|/identify" /opt/punimtag/logs/admin-clicks.log
```

View File

@ -23,6 +23,7 @@ import Help from './pages/Help'
import Layout from './components/Layout'
import PasswordChangeModal from './components/PasswordChangeModal'
import AdminRoute from './components/AdminRoute'
import { logClick, flushPendingClicks } from './services/clickLogger'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth()
@ -57,6 +58,38 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
}
function AppRoutes() {
const { isAuthenticated } = useAuth()
// Set up global click logging for authenticated users
useEffect(() => {
if (!isAuthenticated) {
return
}
const handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target) {
logClick(target)
}
}
// Add click listener
document.addEventListener('click', handleClick, true) // Use capture phase
// Flush pending clicks on page unload
const handleBeforeUnload = () => {
flushPendingClicks()
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => {
document.removeEventListener('click', handleClick, true)
window.removeEventListener('beforeunload', handleBeforeUnload)
// Flush any pending clicks on cleanup
flushPendingClicks()
}
}, [isAuthenticated])
return (
<Routes>
<Route path="/login" element={<Login />} />

View File

@ -0,0 +1,201 @@
/**
* Click logging service for admin frontend.
* Sends click events to backend API for logging to file.
*/
import { apiClient } from '../api/client'
interface ClickLogData {
page: string
element_type: string
element_id?: string
element_text?: string
context?: Record<string, unknown>
}
// Batch clicks to avoid excessive API calls
const CLICK_BATCH_SIZE = 10
const CLICK_BATCH_DELAY = 1000 // 1 second
let clickQueue: ClickLogData[] = []
let batchTimeout: number | null = null
/**
* Get the current page path.
*/
function getCurrentPage(): string {
return window.location.pathname
}
/**
* Get element type from HTML element.
*/
function getElementType(element: HTMLElement): string {
const tagName = element.tagName.toLowerCase()
// Map common elements
if (tagName === 'button' || element.getAttribute('role') === 'button') {
return 'button'
}
if (tagName === 'a') {
return 'link'
}
if (tagName === 'input') {
return 'input'
}
if (tagName === 'select') {
return 'select'
}
if (tagName === 'textarea') {
return 'textarea'
}
// Check for clickable elements
if (element.onclick || element.getAttribute('onclick')) {
return 'clickable'
}
// Default to tag name
return tagName
}
/**
* Get element text content (truncated to 100 chars).
*/
function getElementText(element: HTMLElement): string {
const text = element.textContent?.trim() || element.getAttribute('aria-label') || ''
return text.substring(0, 100)
}
/**
* Extract context from element (data attributes, etc.).
*/
function extractContext(element: HTMLElement): Record<string, unknown> {
const context: Record<string, unknown> = {}
// Extract data-* attributes
Array.from(element.attributes).forEach(attr => {
if (attr.name.startsWith('data-')) {
const key = attr.name.replace('data-', '').replace(/-/g, '_')
context[key] = attr.value
}
})
// Extract common IDs that might be useful
const id = element.id
if (id) {
context.element_id = id
}
const className = element.className
if (className && typeof className === 'string') {
context.class_name = className.split(' ').slice(0, 3).join(' ') // First 3 classes
}
return context
}
/**
* Flush queued clicks to backend.
*/
async function flushClickQueue(): Promise<void> {
if (clickQueue.length === 0) {
return
}
const clicksToSend = [...clickQueue]
clickQueue = []
// Send clicks in parallel (but don't wait for all to complete)
clicksToSend.forEach(clickData => {
apiClient.post('/api/v1/log/click', clickData).catch(error => {
// Silently fail - don't interrupt user experience
console.debug('Click logging failed:', error)
})
})
}
/**
* Queue a click for logging.
*/
function queueClick(clickData: ClickLogData): void {
clickQueue.push(clickData)
// Flush if batch size reached
if (clickQueue.length >= CLICK_BATCH_SIZE) {
if (batchTimeout !== null) {
window.clearTimeout(batchTimeout)
batchTimeout = null
}
flushClickQueue()
} else {
// Set timeout to flush after delay
if (batchTimeout === null) {
batchTimeout = window.setTimeout(() => {
batchTimeout = null
flushClickQueue()
}, CLICK_BATCH_DELAY)
}
}
}
/**
* Log a click event.
*/
export function logClick(
element: HTMLElement,
additionalContext?: Record<string, unknown>
): void {
try {
const elementType = getElementType(element)
const elementId = element.id || undefined
const elementText = getElementText(element)
const page = getCurrentPage()
const context = {
...extractContext(element),
...additionalContext,
}
// Skip logging for certain elements (to reduce noise)
const skipSelectors = [
'input[type="password"]',
'input[type="hidden"]',
'[data-no-log]', // Allow opt-out via data attribute
]
const shouldSkip = skipSelectors.some(selector => {
try {
return element.matches(selector)
} catch {
return false
}
})
if (shouldSkip) {
return
}
queueClick({
page,
element_type: elementType,
element_id: elementId,
element_text: elementText || undefined,
context: Object.keys(context).length > 0 ? context : undefined,
})
} catch (error) {
// Silently fail - don't interrupt user experience
console.debug('Click logging error:', error)
}
}
/**
* Flush any pending clicks (useful on page unload).
*/
export function flushPendingClicks(): void {
if (batchTimeout !== null) {
window.clearTimeout(batchTimeout)
batchTimeout = null
}
flushClickQueue()
}

56
backend/api/click_log.py Normal file
View File

@ -0,0 +1,56 @@
"""Click logging API endpoint."""
from __future__ import annotations
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from backend.api.auth import get_current_user
from backend.utils.click_logger import log_click
router = APIRouter(prefix="/log", tags=["logging"])
class ClickLogRequest(BaseModel):
"""Request model for click logging."""
page: str
element_type: str
element_id: Optional[str] = None
element_text: Optional[str] = None
context: Optional[dict] = None
@router.post("/click")
def log_click_event(
request: ClickLogRequest,
current_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
"""Log a click event from the admin frontend.
Args:
request: Click event data
current_user: Authenticated user (from JWT token)
Returns:
Success confirmation
"""
username = current_user.get("username", "unknown")
try:
log_click(
username=username,
page=request.page,
element_type=request.element_type,
element_id=request.element_id,
element_text=request.element_text,
context=request.context,
)
return {"status": "ok", "message": "Click logged"}
except Exception as e:
# Don't fail the request if logging fails
# Just return success but log the error
import logging
logging.error(f"Failed to log click: {e}")
return {"status": "ok", "message": "Click logged (with errors)"}

View File

@ -26,6 +26,7 @@ from backend.api.users import router as users_router
from backend.api.auth_users import router as auth_users_router
from backend.api.role_permissions import router as role_permissions_router
from backend.api.videos import router as videos_router
from backend.api.click_log import router as click_log_router
from backend.api.version import router as version_router
from backend.settings import APP_TITLE, APP_VERSION
from backend.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES
@ -747,6 +748,7 @@ def create_app() -> FastAPI:
app.include_router(users_router, prefix="/api/v1")
app.include_router(auth_users_router, prefix="/api/v1")
app.include_router(role_permissions_router, prefix="/api/v1")
app.include_router(click_log_router, prefix="/api/v1")
return app

View File

@ -0,0 +1,123 @@
"""Click logging utility with file rotation and management."""
from __future__ import annotations
import os
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional
# Log directory - relative to project root
LOG_DIR = Path(__file__).parent.parent.parent / "logs"
LOG_FILE = LOG_DIR / "admin-clicks.log"
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
BACKUP_COUNT = 5 # Keep 5 rotated files
RETENTION_DAYS = 30 # Keep logs for 30 days
# Ensure log directory exists
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Configure logger with rotation
_logger: Optional[logging.Logger] = None
def get_click_logger() -> logging.Logger:
"""Get or create the click logger with rotation."""
global _logger
if _logger is not None:
return _logger
_logger = logging.getLogger("admin_clicks")
_logger.setLevel(logging.INFO)
# Remove existing handlers to avoid duplicates
_logger.handlers.clear()
# Create rotating file handler
from logging.handlers import RotatingFileHandler
handler = RotatingFileHandler(
LOG_FILE,
maxBytes=MAX_FILE_SIZE,
backupCount=BACKUP_COUNT,
encoding='utf-8'
)
# Simple format: timestamp | username | page | element_type | element_id | element_text | context
formatter = logging.Formatter(
'%(asctime)s | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
handler.setFormatter(formatter)
_logger.addHandler(handler)
# Prevent propagation to root logger
_logger.propagate = False
return _logger
def log_click(
username: str,
page: str,
element_type: str,
element_id: Optional[str] = None,
element_text: Optional[str] = None,
context: Optional[dict] = None,
) -> None:
"""Log a click event to the log file.
Args:
username: Username of the user who clicked
page: Page/route where click occurred (e.g., '/identify')
element_type: Type of element (button, link, input, etc.)
element_id: ID of the element (optional)
element_text: Text content of the element (optional)
context: Additional context as dict (optional, will be JSON stringified)
"""
logger = get_click_logger()
# Format context as JSON string if provided
context_str = ""
if context:
import json
try:
context_str = f" | {json.dumps(context)}"
except (TypeError, ValueError):
context_str = f" | {str(context)}"
# Build log message
parts = [
username,
page,
element_type,
element_id or "",
element_text or "",
]
# Join parts with | separator, remove empty parts
message = " | ".join(part for part in parts if part) + context_str
logger.info(message)
def cleanup_old_logs() -> None:
"""Remove log files older than RETENTION_DAYS."""
if not LOG_DIR.exists():
return
from datetime import timedelta
cutoff_date = datetime.now() - timedelta(days=RETENTION_DAYS)
for log_file in LOG_DIR.glob("admin-clicks.log.*"):
try:
# Check file modification time
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
if mtime < cutoff_date:
log_file.unlink()
except (OSError, ValueError):
# Skip files we can't process
pass

22
scripts/cleanup-click-logs.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/bash
# Cleanup old click log files (older than retention period)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
LOG_DIR="$PROJECT_ROOT/logs"
if [ ! -d "$LOG_DIR" ]; then
echo "Log directory does not exist: $LOG_DIR"
exit 0
fi
# Run Python cleanup function
cd "$PROJECT_ROOT"
python3 -c "
from backend.utils.click_logger import cleanup_old_logs
cleanup_old_logs()
print('✅ Old click logs cleaned up')
" 2>&1