mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat: enhance audit logs with geo flags, relative time, and UX improvements (#2760)
This commit is contained in:
@@ -21,5 +21,6 @@ public class AuditLogDto {
|
||||
private Long entityId;
|
||||
private String description;
|
||||
private String ipAddress;
|
||||
private String countryCode;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -41,6 +41,9 @@ public class AuditLogEntity {
|
||||
@Column(name = "ip_address", length = 45)
|
||||
private String ipAddress;
|
||||
|
||||
@Column(name = "country_code", length = 2)
|
||||
private String countryCode;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.List;
|
||||
public class AuditService {
|
||||
|
||||
private final AuditLogRepository auditLogRepository;
|
||||
private final GeoIpService geoIpService;
|
||||
|
||||
public void log(AuditAction action, String description) {
|
||||
log(action, null, null, description);
|
||||
@@ -46,6 +47,8 @@ public class AuditService {
|
||||
// Non-HTTP context (scheduled tasks, etc.)
|
||||
}
|
||||
|
||||
String countryCode = geoIpService.resolveCountryCode(ipAddress);
|
||||
|
||||
AuditLogEntity entity = AuditLogEntity.builder()
|
||||
.userId(userId)
|
||||
.username(username)
|
||||
@@ -54,6 +57,7 @@ public class AuditService {
|
||||
.entityId(entityId)
|
||||
.description(description)
|
||||
.ipAddress(ipAddress)
|
||||
.countryCode(countryCode)
|
||||
.build();
|
||||
|
||||
auditLogRepository.save(entity);
|
||||
@@ -86,6 +90,7 @@ public class AuditService {
|
||||
.entityId(entity.getEntityId())
|
||||
.description(entity.getDescription())
|
||||
.ipAddress(entity.getIpAddress())
|
||||
.countryCode(entity.getCountryCode())
|
||||
.createdAt(entity.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.booklore.service.audit;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class GeoIpService {
|
||||
|
||||
private static final String GEO_API_URL = "http://ip-api.com/json/%s?fields=countryCode";
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(2);
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final Map<String, String> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public String resolveCountryCode(String ip) {
|
||||
if (ip == null || ip.isBlank() || isPrivateOrLocal(ip)) {
|
||||
return null;
|
||||
}
|
||||
String result = cache.get(ip);
|
||||
if (result == null) {
|
||||
result = fetchCountryCode(ip);
|
||||
cache.put(ip, result);
|
||||
}
|
||||
return result.isEmpty() ? null : result;
|
||||
}
|
||||
|
||||
private String fetchCountryCode(String ip) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(String.format(GEO_API_URL, ip)))
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.GET()
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() == 200) {
|
||||
JsonNode node = objectMapper.readTree(response.body());
|
||||
if (node.has("countryCode") && !node.get("countryCode").asText().isBlank()) {
|
||||
return node.get("countryCode").asText();
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.debug("Interrupted while resolving country code for IP: {}", ip);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to resolve country code for IP: {}", ip);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private boolean isPrivateOrLocal(String ip) {
|
||||
try {
|
||||
InetAddress addr = InetAddress.getByName(ip);
|
||||
return addr.isLoopbackAddress() || addr.isSiteLocalAddress() || addr.isLinkLocalAddress();
|
||||
} catch (Exception e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS country_code CHAR(2) NULL;
|
||||
@@ -12,6 +12,7 @@ export interface AuditLog {
|
||||
entityId: number | null;
|
||||
description: string;
|
||||
ipAddress: string | null;
|
||||
countryCode: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,20 @@
|
||||
[showButtonBar]="true"
|
||||
class="date-range-filter">
|
||||
</p-datepicker>
|
||||
<button
|
||||
*ngIf="hasActiveFilters"
|
||||
(click)="clearAllFilters()"
|
||||
class="icon-btn clear-btn"
|
||||
[title]="t('clearFilters')">
|
||||
<i class="pi pi-filter-slash"></i>
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleAutoRefresh()"
|
||||
class="icon-btn refresh-btn"
|
||||
[class.active]="autoRefresh"
|
||||
[title]="t('autoRefresh')">
|
||||
<i class="pi pi-sync" [class.pi-spin]="autoRefresh"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
@@ -61,25 +75,34 @@
|
||||
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th style="width: 175px">{{ t('columns.timestamp') }}</th>
|
||||
<th style="width: 120px">{{ t('columns.timestamp') }}</th>
|
||||
<th style="width: 100px">{{ t('columns.user') }}</th>
|
||||
<th style="width: 140px">{{ t('columns.action') }}</th>
|
||||
<th>{{ t('columns.description') }}</th>
|
||||
<th style="width: 130px">{{ t('columns.ip') }}</th>
|
||||
<th style="width: 160px">{{ t('columns.ip') }}</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
<ng-template pTemplate="body" let-log>
|
||||
<tr>
|
||||
<td class="timestamp-cell">{{ log.createdAt | date:'medium' }}</td>
|
||||
<td class="timestamp-cell" [title]="log.createdAt | date:'medium'">
|
||||
{{ getRelativeTime(log.createdAt) }}
|
||||
</td>
|
||||
<td>{{ log.username }}</td>
|
||||
<td>
|
||||
<span class="action-badge" [ngClass]="getActionClass(log.action)">
|
||||
{{ formatAction(log.action) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="description-cell">{{ log.description }}</td>
|
||||
<td class="ip-cell">{{ log.ipAddress || '—' }}</td>
|
||||
<td class="description-cell"
|
||||
[class.expanded]="expandedRows.has(log.id)"
|
||||
(click)="toggleDescription(log.id)">
|
||||
{{ log.description }}
|
||||
</td>
|
||||
<td class="ip-cell">
|
||||
<span *ngIf="log.countryCode" class="flag-emoji">{{ countryCodeToFlag(log.countryCode) }}</span>
|
||||
{{ log.ipAddress || '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
|
||||
|
||||
@@ -57,6 +57,31 @@
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--p-text-muted-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--p-content-hover-background);
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
|
||||
color: var(--p-primary-color);
|
||||
border-color: var(--p-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .p-datatable-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
@@ -95,12 +120,19 @@
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
color: var(--p-text-muted-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.description-cell {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
&.expanded {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.ip-cell {
|
||||
@@ -109,6 +141,12 @@
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
|
||||
.flag-emoji {
|
||||
font-size: 1.15rem;
|
||||
margin-right: 0.25rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
::ng-deep .p-datatable.p-datatable-sm .p-datatable-tbody > tr > td.empty-message {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {Component, inject, OnInit, OnDestroy} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TableLazyLoadEvent, TableModule} from 'primeng/table';
|
||||
import {Select} from 'primeng/select';
|
||||
import {DatePicker} from 'primeng/datepicker';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {TranslocoDirective} from '@jsverse/transloco';
|
||||
import {Subscription, interval} from 'rxjs';
|
||||
import {AuditLog, AuditLogService} from './audit-log.service';
|
||||
|
||||
interface ActionOption {
|
||||
@@ -24,7 +25,7 @@ interface UsernameOption {
|
||||
templateUrl: './audit-logs.component.html',
|
||||
styleUrl: './audit-logs.component.scss'
|
||||
})
|
||||
export class AuditLogsComponent implements OnInit {
|
||||
export class AuditLogsComponent implements OnInit, OnDestroy {
|
||||
private readonly auditLogService = inject(AuditLogService);
|
||||
|
||||
logs: AuditLog[] = [];
|
||||
@@ -34,6 +35,9 @@ export class AuditLogsComponent implements OnInit {
|
||||
selectedAction: string | null = null;
|
||||
selectedUsername: string | null = null;
|
||||
dateRange: Date[] | null = null;
|
||||
expandedRows = new Set<number>();
|
||||
autoRefresh = false;
|
||||
private autoRefreshSub?: Subscription;
|
||||
|
||||
usernameOptions: UsernameOption[] = [{label: 'All Users', value: ''}];
|
||||
|
||||
@@ -79,6 +83,10 @@ export class AuditLogsComponent implements OnInit {
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.autoRefreshSub?.unsubscribe();
|
||||
}
|
||||
|
||||
loadUsernames(): void {
|
||||
this.auditLogService.getDistinctUsernames().subscribe({
|
||||
next: (usernames) => {
|
||||
@@ -90,8 +98,10 @@ export class AuditLogsComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
loadLogs(): void {
|
||||
this.loading = true;
|
||||
loadLogs(showLoading = true): void {
|
||||
if (showLoading) {
|
||||
this.loading = true;
|
||||
}
|
||||
const action = this.selectedAction || undefined;
|
||||
const username = this.selectedUsername || undefined;
|
||||
const from = this.dateRange?.[0] ? this.formatDateTime(this.dateRange[0]) : undefined;
|
||||
@@ -124,6 +134,59 @@ export class AuditLogsComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
toggleAutoRefresh(): void {
|
||||
this.autoRefresh = !this.autoRefresh;
|
||||
if (this.autoRefresh) {
|
||||
this.autoRefreshSub = interval(10000).subscribe(() => this.loadLogs(false));
|
||||
} else {
|
||||
this.autoRefreshSub?.unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
clearAllFilters(): void {
|
||||
this.selectedAction = null;
|
||||
this.selectedUsername = null;
|
||||
this.dateRange = null;
|
||||
this.currentPage = 0;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
get hasActiveFilters(): boolean {
|
||||
return !!this.selectedAction || !!this.selectedUsername ||
|
||||
(this.dateRange != null && this.dateRange.length > 0 && this.dateRange[0] != null);
|
||||
}
|
||||
|
||||
toggleDescription(logId: number): void {
|
||||
if (this.expandedRows.has(logId)) {
|
||||
this.expandedRows.delete(logId);
|
||||
} else {
|
||||
this.expandedRows.add(logId);
|
||||
}
|
||||
}
|
||||
|
||||
countryCodeToFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return '';
|
||||
return [...code.toUpperCase()]
|
||||
.map(c => String.fromCodePoint(0x1F1E6 + c.charCodeAt(0) - 65))
|
||||
.join('');
|
||||
}
|
||||
|
||||
getRelativeTime(dateStr: string): string {
|
||||
const now = new Date();
|
||||
const date = new Date(dateStr);
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
|
||||
if (diffSec < 60) return 'Just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHour < 24) return `${diffHour}h ago`;
|
||||
if (diffDay < 30) return `${diffDay}d ago`;
|
||||
return new Intl.DateTimeFormat(undefined, {dateStyle: 'medium'}).format(date);
|
||||
}
|
||||
|
||||
formatAction(action: string): string {
|
||||
return action.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(/\B\w+/g, c => c.toLowerCase());
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"filterPlaceholder": "Filter by action",
|
||||
"userFilterPlaceholder": "Filter by user",
|
||||
"dateRangePlaceholder": "Filter by date range",
|
||||
"clearFilters": "Clear all filters",
|
||||
"autoRefresh": "Auto-refresh (10s)",
|
||||
"pageReport": "Showing {first} to {last} of {totalRecords} entries",
|
||||
"empty": "No audit logs found",
|
||||
"columns": {
|
||||
"timestamp": "Date & Time",
|
||||
"timestamp": "When",
|
||||
"user": "User",
|
||||
"action": "Action",
|
||||
"description": "Description",
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
"filterPlaceholder": "Filtrar por acción",
|
||||
"userFilterPlaceholder": "Filtrar por usuario",
|
||||
"dateRangePlaceholder": "Filtrar por rango de fechas",
|
||||
"clearFilters": "Borrar todos los filtros",
|
||||
"autoRefresh": "Actualización automática (10s)",
|
||||
"pageReport": "Mostrando {first} a {last} de {totalRecords} entradas",
|
||||
"empty": "No se encontraron registros de auditoría",
|
||||
"columns": {
|
||||
"timestamp": "Fecha y hora",
|
||||
"timestamp": "Cuándo",
|
||||
"user": "Usuario",
|
||||
"action": "Acción",
|
||||
"description": "Descripción",
|
||||
|
||||
Reference in New Issue
Block a user