feature/extend-people-search-and-fix-port-binding #34
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -46,8 +46,8 @@ export const peopleApi = {
|
||||
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
|
||||
return res.data
|
||||
},
|
||||
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
|
||||
const params = lastName ? { last_name: lastName } : {}
|
||||
listWithFaces: async (name?: string): Promise<PeopleWithFacesListResponse> => {
|
||||
const params = name ? { last_name: name } : {}
|
||||
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
|
||||
return res.data
|
||||
},
|
||||
|
||||
@ -672,7 +672,7 @@ function ModifyPageHelp({ onBack }: { onBack: () => void }) {
|
||||
<p className="text-gray-700 font-medium mb-2">Finding and Selecting a Person:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-gray-600 ml-4">
|
||||
<li>Navigate to Modify page</li>
|
||||
<li>Optionally search for a person by entering their last name or maiden name in the search box</li>
|
||||
<li>Optionally search for a person by entering their first, middle, last, or maiden name in the search box</li>
|
||||
<li>Click "Search" to filter the list, or "Clear" to show all people</li>
|
||||
<li>Click on a person's name in the left panel to select them</li>
|
||||
<li>The person's faces and videos will load in the right panels</li>
|
||||
|
||||
@ -147,7 +147,7 @@ function EditPersonDialog({ person, onSave, onClose }: EditDialogProps) {
|
||||
|
||||
export default function Modify() {
|
||||
const [people, setPeople] = useState<PersonWithFaces[]>([])
|
||||
const [lastNameFilter, setLastNameFilter] = useState('')
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null)
|
||||
const [selectedPersonName, setSelectedPersonName] = useState('')
|
||||
const [faces, setFaces] = useState<PersonFaceItem[]>([])
|
||||
@ -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() {
|
||||
<div className="flex gap-2 mb-1">
|
||||
<input
|
||||
type="text"
|
||||
value={lastNameFilter}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
@ -739,7 +742,7 @@ export default function Modify() {
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Search by Last Name or Maiden Name</p>
|
||||
<p className="text-xs text-gray-500">Search by First, Middle, Last, or Maiden Name</p>
|
||||
</div>
|
||||
|
||||
{/* People list */}
|
||||
|
||||
@ -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<void>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedTagName, setSelectedTagName] = useState('')
|
||||
const [newTagName, setNewTagName] = useState('')
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(new Set())
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
const [photoTagsData, setPhotoTagsData] = useState<Record<number, any[]>>({})
|
||||
const [localTags, setLocalTags] = useState<TagResponse[]>(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<number, any[]> = {}
|
||||
@ -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<number>()
|
||||
@ -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({
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 flex-1 overflow-auto">
|
||||
<div className="mb-4 flex gap-2">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Existing Tag:
|
||||
</label>
|
||||
<select
|
||||
value={selectedTagName}
|
||||
onChange={(e) => setSelectedTagName(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded"
|
||||
>
|
||||
<option value="">Select tag...</option>
|
||||
{tags.map(tag => (
|
||||
@ -1974,13 +2022,29 @@ function TagSelectedPhotosDialog({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!selectedTagName.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
You can select an existing tag and enter a new tag name to add both at once.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Enter New Tag Name:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTagName}
|
||||
onChange={(e) => 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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
New tags will be created in the database automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -2024,12 +2088,21 @@ function TagSelectedPhotosDialog({
|
||||
>
|
||||
Remove selected tags
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddTag}
|
||||
disabled={!selectedTagName.trim() && !newTagName.trim()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
Add Tag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
201
admin-frontend/src/services/clickLogger.ts
Normal file
201
admin-frontend/src/services/clickLogger.ts
Normal 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
56
backend/api/click_log.py
Normal 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)"}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
123
backend/utils/click_logger.py
Normal file
123
backend/utils/click_logger.py
Normal 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
22
scripts/cleanup-click-logs.sh
Executable 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
|
||||
|
||||
13
scripts/start-api.sh
Executable file
13
scripts/start-api.sh
Executable file
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user