feat: enhance audit logs with geo flags, relative time, and UX improvements (#2760)

This commit is contained in:
ACX
2026-02-15 08:11:39 -07:00
committed by GitHub
parent c9551ef4ab
commit 0318d1b3bb
11 changed files with 224 additions and 11 deletions

View File

@@ -21,5 +21,6 @@ public class AuditLogDto {
private Long entityId;
private String description;
private String ipAddress;
private String countryCode;
private LocalDateTime createdAt;
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1 @@
ALTER TABLE audit_log ADD COLUMN IF NOT EXISTS country_code CHAR(2) NULL;

View File

@@ -12,6 +12,7 @@ export interface AuditLog {
entityId: number | null;
description: string;
ipAddress: string | null;
countryCode: string | null;
createdAt: string;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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());
}

View File

@@ -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",

View File

@@ -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",