diff --git a/QUICK_LOG_REFERENCE.md b/QUICK_LOG_REFERENCE.md index f49368e..df54351 100644 --- a/QUICK_LOG_REFERENCE.md +++ b/QUICK_LOG_REFERENCE.md @@ -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 + ``` + diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 81c1ab9..e58b82e 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -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 ( } /> diff --git a/admin-frontend/src/api/people.ts b/admin-frontend/src/api/people.ts index 99b55f5..4a0712a 100644 --- a/admin-frontend/src/api/people.ts +++ b/admin-frontend/src/api/people.ts @@ -46,8 +46,8 @@ export const peopleApi = { const res = await apiClient.get('/api/v1/people', { params }) return res.data }, - listWithFaces: async (lastName?: string): Promise => { - const params = lastName ? { last_name: lastName } : {} + listWithFaces: async (name?: string): Promise => { + const params = name ? { last_name: name } : {} const res = await apiClient.get('/api/v1/people/with-faces', { params }) return res.data }, diff --git a/admin-frontend/src/pages/Help.tsx b/admin-frontend/src/pages/Help.tsx index 07dd36b..b1df125 100644 --- a/admin-frontend/src/pages/Help.tsx +++ b/admin-frontend/src/pages/Help.tsx @@ -672,7 +672,7 @@ function ModifyPageHelp({ onBack }: { onBack: () => void }) {

Finding and Selecting a Person:

  1. Navigate to Modify page
  2. -
  3. Optionally search for a person by entering their last name or maiden name in the search box
  4. +
  5. Optionally search for a person by entering their first, middle, last, or maiden name in the search box
  6. Click "Search" to filter the list, or "Clear" to show all people
  7. Click on a person's name in the left panel to select them
  8. The person's faces and videos will load in the right panels
  9. diff --git a/admin-frontend/src/pages/Modify.tsx b/admin-frontend/src/pages/Modify.tsx index 0fc9ee7..1bf799d 100644 --- a/admin-frontend/src/pages/Modify.tsx +++ b/admin-frontend/src/pages/Modify.tsx @@ -147,7 +147,7 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) { export default function Modify() { const [people, setPeople] = useState([]) - const [lastNameFilter, setLastNameFilter] = useState('') + const [nameFilter, setNameFilter] = useState('') const [selectedPersonId, setSelectedPersonId] = useState(null) const [selectedPersonName, setSelectedPersonName] = useState('') const [faces, setFaces] = useState([]) @@ -187,7 +187,7 @@ export default function Modify() { try { setBusy(true) setError(null) - const res = await peopleApi.listWithFaces(lastNameFilter || undefined) + const res = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(res.items) // Auto-select first person if available and none selected (only if not restoring state) @@ -203,7 +203,7 @@ export default function Modify() { } finally { setBusy(false) } - }, [lastNameFilter, selectedPersonId]) + }, [nameFilter, selectedPersonId]) // Load faces for a person const loadPersonFaces = useCallback(async (personId: number) => { @@ -248,12 +248,15 @@ export default function Modify() { useEffect(() => { let restoredPanelWidth = false try { - const saved = sessionStorage.getItem(STATE_KEY) - if (saved) { - const state = JSON.parse(saved) - if (state.lastNameFilter !== undefined) { - setLastNameFilter(state.lastNameFilter || '') - } + const saved = sessionStorage.getItem(STATE_KEY) + if (saved) { + const state = JSON.parse(saved) + if (state.nameFilter !== undefined) { + setNameFilter(state.nameFilter || '') + } else if (state.lastNameFilter !== undefined) { + // Backward compatibility with old state key + setNameFilter(state.lastNameFilter || '') + } if (state.selectedPersonId !== undefined && state.selectedPersonId !== null) { setSelectedPersonId(state.selectedPersonId) } @@ -365,7 +368,7 @@ export default function Modify() { try { const state = { - lastNameFilter, + nameFilter, selectedPersonId, selectedPersonName, faces, @@ -380,10 +383,10 @@ export default function Modify() { } catch (error) { console.error('Error saving state to sessionStorage:', error) } - }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored]) + }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth, stateRestored]) // Save state on unmount (when navigating away) - use refs to capture latest values - const lastNameFilterRef = useRef(lastNameFilter) + const nameFilterRef = useRef(nameFilter) const selectedPersonIdRef = useRef(selectedPersonId) const selectedPersonNameRef = useRef(selectedPersonName) const facesRef = useRef(faces) @@ -396,7 +399,7 @@ export default function Modify() { // Update refs whenever state changes useEffect(() => { - lastNameFilterRef.current = lastNameFilter + nameFilterRef.current = nameFilter selectedPersonIdRef.current = selectedPersonId selectedPersonNameRef.current = selectedPersonName facesRef.current = faces @@ -406,14 +409,14 @@ export default function Modify() { facesExpandedRef.current = facesExpanded videosExpandedRef.current = videosExpanded peoplePanelWidthRef.current = peoplePanelWidth - }, [lastNameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth]) + }, [nameFilter, selectedPersonId, selectedPersonName, faces, videos, selectedFaces, selectedVideos, facesExpanded, videosExpanded, peoplePanelWidth]) // Save state on unmount (when navigating away) useEffect(() => { return () => { try { const state = { - lastNameFilter: lastNameFilterRef.current, + nameFilter: nameFilterRef.current, selectedPersonId: selectedPersonIdRef.current, selectedPersonName: selectedPersonNameRef.current, faces: facesRef.current, @@ -463,7 +466,7 @@ export default function Modify() { } const handleClearSearch = () => { - setLastNameFilter('') + setNameFilter('') // loadPeople will be called by useEffect } @@ -560,7 +563,7 @@ export default function Modify() { await facesApi.batchUnmatch({ face_ids: [unmatchConfirmDialog.faceId] }) // Reload people list to update face counts - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload faces @@ -591,7 +594,7 @@ export default function Modify() { setSelectedFaces(new Set()) // Reload people list to update face counts - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload faces @@ -627,7 +630,7 @@ export default function Modify() { await videosApi.removePerson(unmatchConfirmDialog.videoId, selectedPersonId) // Reload people list - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload videos @@ -679,7 +682,7 @@ export default function Modify() { setSelectedVideos(new Set()) // Reload people list - const peopleRes = await peopleApi.listWithFaces(lastNameFilter || undefined) + const peopleRes = await peopleApi.listWithFaces(nameFilter || undefined) setPeople(peopleRes.items) // Reload videos @@ -720,10 +723,10 @@ export default function Modify() {
    setLastNameFilter(e.target.value)} + value={nameFilter} + onChange={(e) => setNameFilter(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - placeholder="Type Last Name or Maiden Name" + placeholder="Type First, Middle, Last, or Maiden Name" className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" />
    -

    Search by Last Name or Maiden Name

    +

    Search by First, Middle, Last, or Maiden Name

    {/* People list */} diff --git a/admin-frontend/src/pages/Tags.tsx b/admin-frontend/src/pages/Tags.tsx index f139608..a349730 100644 --- a/admin-frontend/src/pages/Tags.tsx +++ b/admin-frontend/src/pages/Tags.tsx @@ -1115,6 +1115,11 @@ export default function Tags() { selectedPhotoIds={Array.from(selectedPhotoIds)} photos={photos.filter(p => selectedPhotoIds.has(p.id))} tags={tags} + onTagsUpdated={async () => { + // Reload tags when new tags are created + const tagsRes = await tagsApi.list() + setTags(tagsRes.items) + }} onClose={async () => { setShowTagSelectedDialog(false) setSelectedPhotoIds(new Set()) @@ -1775,17 +1780,26 @@ function TagSelectedPhotosDialog({ selectedPhotoIds, photos, tags, + onTagsUpdated, onClose, }: { selectedPhotoIds: number[] photos: PhotoWithTagsItem[] tags: TagResponse[] + onTagsUpdated?: () => Promise onClose: () => void }) { const [selectedTagName, setSelectedTagName] = useState('') + const [newTagName, setNewTagName] = useState('') const [selectedTagIds, setSelectedTagIds] = useState>(new Set()) const [showConfirmDialog, setShowConfirmDialog] = useState(false) const [photoTagsData, setPhotoTagsData] = useState>({}) + const [localTags, setLocalTags] = useState(tags) + + // Update local tags when tags prop changes + useEffect(() => { + setLocalTags(tags) + }, [tags]) // Load tag linkage information for all selected photos useEffect(() => { @@ -1809,28 +1823,59 @@ function TagSelectedPhotosDialog({ }, [selectedPhotoIds]) const handleAddTag = async () => { - if (!selectedTagName.trim() || selectedPhotoIds.length === 0) return + if (selectedPhotoIds.length === 0) return - // Check if tag exists, create if not - let tag = tags.find(t => t.tag_name.toLowerCase() === selectedTagName.toLowerCase().trim()) - if (!tag) { - try { - tag = await tagsApi.create(selectedTagName.trim()) - // Note: We don't update the tags list here since it's passed from parent - } catch (error) { - console.error('Failed to create tag:', error) - alert('Failed to create tag') - return - } + // Collect both tags: selected existing tag and new tag name + const tagsToAdd: string[] = [] + + if (selectedTagName.trim()) { + tagsToAdd.push(selectedTagName.trim()) + } + + if (newTagName.trim()) { + tagsToAdd.push(newTagName.trim()) + } + + if (tagsToAdd.length === 0) { + alert('Please select a tag or enter a new tag name.') + return } - // Make single batch API call for all selected photos try { + // Create any new tags first + const newTags = tagsToAdd.filter(tag => + !localTags.some(availableTag => + availableTag.tag_name.toLowerCase() === tag.toLowerCase() + ) + ) + + if (newTags.length > 0) { + const createdTags: TagResponse[] = [] + for (const newTag of newTags) { + const createdTag = await tagsApi.create(newTag) + createdTags.push(createdTag) + } + // Update local tags immediately with newly created tags + setLocalTags(prev => { + const updated = [...prev, ...createdTags] + // Sort by tag name + return updated.sort((a, b) => a.tag_name.localeCompare(b.tag_name)) + }) + // Also reload tags list in parent to keep it in sync + if (onTagsUpdated) { + await onTagsUpdated() + } + } + + // Add all tags to photos in a single API call await tagsApi.addToPhotos({ photo_ids: selectedPhotoIds, - tag_names: [selectedTagName.trim()], + tag_names: tagsToAdd, }) + + // Clear inputs after successful tagging setSelectedTagName('') + setNewTagName('') // Reload photo tags data to update the common tags list const tagsData: Record = {} @@ -1901,7 +1946,7 @@ function TagSelectedPhotosDialog({ allPhotoTags[photoId] = photoTagsData[photoId] || [] }) - const tagIdToName = new Map(tags.map(t => [t.id, t.tag_name])) + const tagIdToName = new Map(localTags.map(t => [t.id, t.tag_name])) // Get all unique tag IDs from all photos const allTagIds = new Set() @@ -1930,7 +1975,7 @@ function TagSelectedPhotosDialog({ } }) .filter(Boolean) as any[] - }, [photos, tags, selectedPhotoIds, photoTagsData]) + }, [photos, localTags, selectedPhotoIds, photoTagsData]) // Get selected tag names for confirmation message const selectedTagNames = useMemo(() => { @@ -1961,11 +2006,14 @@ function TagSelectedPhotosDialog({

    -
    +
    + - +

    + You can select an existing tag and enter a new tag name to add both at once. +

    +
    +
    + + setNewTagName(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded" + placeholder="Type new tag name..." + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleAddTag() + } + }} + /> +

    + New tags will be created in the database automatically. +

    @@ -2024,12 +2088,21 @@ function TagSelectedPhotosDialog({ > Remove selected tags - +
    + + +
    diff --git a/admin-frontend/src/services/clickLogger.ts b/admin-frontend/src/services/clickLogger.ts new file mode 100644 index 0000000..b8917a9 --- /dev/null +++ b/admin-frontend/src/services/clickLogger.ts @@ -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 +} + +// 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 { + const context: Record = {} + + // 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 { + 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 +): 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() +} + diff --git a/backend/api/click_log.py b/backend/api/click_log.py new file mode 100644 index 0000000..c2dbe08 --- /dev/null +++ b/backend/api/click_log.py @@ -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)"} + diff --git a/backend/api/people.py b/backend/api/people.py index 2a5165e..c17b001 100644 --- a/backend/api/people.py +++ b/backend/api/people.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Response, status -from sqlalchemy import func +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from backend.db.session import get_db @@ -48,12 +48,12 @@ def list_people( @router.get("/with-faces", response_model=PeopleWithFacesListResponse) def list_people_with_faces( - last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"), + last_name: str | None = Query(None, description="Filter by first, middle, last, or maiden name (case-insensitive)"), db: Session = Depends(get_db), ) -> PeopleWithFacesListResponse: """List all people with face counts and video counts, sorted by last_name, first_name. - Optionally filter by last_name or maiden_name if provided (case-insensitive search). + Optionally filter by first_name, middle_name, last_name, or maiden_name if provided (case-insensitive search). Returns all people, including those with zero faces or videos. """ # Query people with face counts using LEFT OUTER JOIN to include people with no faces @@ -67,11 +67,15 @@ def list_people_with_faces( ) if last_name: - # Case-insensitive search on both last_name and maiden_name + # Case-insensitive search on first_name, middle_name, last_name, and maiden_name search_term = last_name.lower() query = query.filter( - (func.lower(Person.last_name).contains(search_term)) | - ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term))) + or_( + func.lower(Person.first_name).contains(search_term), + func.lower(Person.middle_name).contains(search_term), + func.lower(Person.last_name).contains(search_term), + ((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term))) + ) ) results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all() diff --git a/backend/app.py b/backend/app.py index 1d340ab..d7bba1d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 diff --git a/backend/utils/click_logger.py b/backend/utils/click_logger.py new file mode 100644 index 0000000..7ab28ac --- /dev/null +++ b/backend/utils/click_logger.py @@ -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 + diff --git a/scripts/cleanup-click-logs.sh b/scripts/cleanup-click-logs.sh new file mode 100755 index 0000000..0b77ab4 --- /dev/null +++ b/scripts/cleanup-click-logs.sh @@ -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 + diff --git a/scripts/start-api.sh b/scripts/start-api.sh new file mode 100755 index 0000000..f322b0d --- /dev/null +++ b/scripts/start-api.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Wrapper script to start the API with port cleanup + +# Kill any processes using port 8000 (except our own if we're restarting) +PORT=8000 +lsof -ti :${PORT} | xargs -r kill -9 2>/dev/null || true + +# Wait a moment for port to be released +sleep 2 + +# Start uvicorn +exec /opt/punimtag/venv/bin/uvicorn backend.app:app --host 0.0.0.0 --port 8000 +