feat(api): add server-side pagination and sorting to event logs API
- update event logs API to support pagination and sorting via headers - modify event logs page to handle new API response structure - implement debounced search functionality for improved UX - adjust total count display to reflect actual number of logs
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -49,3 +49,4 @@ Thumbs.db
|
|||||||
/server
|
/server
|
||||||
|
|
||||||
server.log
|
server.log
|
||||||
|
/data/*
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -307,13 +308,17 @@ func handleLogout(w http.ResponseWriter, r *http.Request, secProvider security.S
|
|||||||
|
|
||||||
// QueryRequest represents a unified query request
|
// QueryRequest represents a unified query request
|
||||||
type QueryRequest struct {
|
type QueryRequest struct {
|
||||||
Action string `json:"action"` // "list", "get", "create", "update", "delete"
|
Action string `json:"action"` // "list", "get", "create", "update", "delete"
|
||||||
Table string `json:"table"` // Table name (e.g., "users", "hooks")
|
Table string `json:"table"` // Table name (e.g., "users", "hooks")
|
||||||
ID string `json:"id,omitempty"` // For get/update/delete
|
ID string `json:"id,omitempty"` // For get/update/delete
|
||||||
Data map[string]interface{} `json:"data,omitempty"` // For create/update
|
Data map[string]interface{} `json:"data,omitempty"` // For create/update
|
||||||
Filters map[string]interface{} `json:"filters,omitempty"` // For list
|
Filters map[string]interface{} `json:"filters,omitempty"` // For list — exact match
|
||||||
Limit int `json:"limit,omitempty"`
|
Search string `json:"search,omitempty"` // For list — LIKE across SearchColumns
|
||||||
Offset int `json:"offset,omitempty"`
|
SearchColumns []string `json:"search_columns,omitempty"` // Columns to apply Search against
|
||||||
|
OrderBy string `json:"order_by,omitempty"` // Column to order by
|
||||||
|
OrderDir string `json:"order_dir,omitempty"` // "ASC" or "DESC"
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
Offset int `json:"offset,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleQuery handles unified query requests
|
// handleQuery handles unified query requests
|
||||||
@@ -348,24 +353,45 @@ func handleQuery(w http.ResponseWriter, r *http.Request, db *bun.DB, secProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applySearchAndFilters applies exact-match filters and LIKE search to a select query.
|
||||||
|
func applySearchAndFilters(query *bun.SelectQuery, req QueryRequest) *bun.SelectQuery {
|
||||||
|
for key, value := range req.Filters {
|
||||||
|
query = query.Where("? = ?", bun.Ident(key), value)
|
||||||
|
}
|
||||||
|
if req.Search != "" && len(req.SearchColumns) > 0 {
|
||||||
|
query = query.WhereGroup(" AND ", func(q *bun.SelectQuery) *bun.SelectQuery {
|
||||||
|
for i, col := range req.SearchColumns {
|
||||||
|
if i == 0 {
|
||||||
|
q = q.Where("? LIKE ?", bun.Ident(col), "%"+req.Search+"%")
|
||||||
|
} else {
|
||||||
|
q = q.WhereOr("? LIKE ?", bun.Ident(col), "%"+req.Search+"%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
// handleQueryList lists records from a table
|
// handleQueryList lists records from a table
|
||||||
func handleQueryList(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) {
|
func handleQueryList(w http.ResponseWriter, r *http.Request, db *bun.DB, req QueryRequest, userCtx *security.UserContext) {
|
||||||
// Get model registry to find the model
|
|
||||||
registry := getModelForTable(req.Table)
|
registry := getModelForTable(req.Table)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
http.Error(w, "Table not found", http.StatusNotFound)
|
http.Error(w, "Table not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create slice to hold results
|
|
||||||
results := registry()
|
results := registry()
|
||||||
|
|
||||||
// Build query
|
|
||||||
query := db.NewSelect().Model(results)
|
query := db.NewSelect().Model(results)
|
||||||
|
query = applySearchAndFilters(query, req)
|
||||||
|
|
||||||
// Apply filters
|
// Apply ordering
|
||||||
for key, value := range req.Filters {
|
if req.OrderBy != "" {
|
||||||
query = query.Where("? = ?", bun.Ident(key), value)
|
dir := "ASC"
|
||||||
|
if strings.ToUpper(req.OrderDir) == "DESC" {
|
||||||
|
dir = "DESC"
|
||||||
|
}
|
||||||
|
query = query.OrderExpr("? "+dir, bun.Ident(req.OrderBy))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply limit/offset
|
// Apply limit/offset
|
||||||
@@ -376,9 +402,8 @@ func handleQueryList(w http.ResponseWriter, r *http.Request, db *bun.DB, req Que
|
|||||||
query = query.Offset(req.Offset)
|
query = query.Offset(req.Offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute query
|
|
||||||
if err := query.Scan(r.Context()); err != nil {
|
if err := query.Scan(r.Context()); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Query failed: %v", err), http.StatusInternalServerError)
|
http.Error(w, "Query failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
2
pkg/serverembed/dist/index.html
vendored
2
pkg/serverembed/dist/index.html
vendored
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/ui/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/ui/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>web</title>
|
<title>web</title>
|
||||||
<script type="module" crossorigin src="/ui/assets/index-CExXKuWO.js"></script>
|
<script type="module" crossorigin src="/ui/assets/index-Cj4Q_Qgu.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
|
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
193
security_issues.md
Normal file
193
security_issues.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# Security Issues — WhatsHooked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL
|
||||||
|
|
||||||
|
### 1. Broken Row-Level Security — All JWT Users Get Admin Access
|
||||||
|
**File:** `pkg/api/security.go:236-241`
|
||||||
|
|
||||||
|
`GetRowSecurity()` checks `if userID == 0` → returns empty filter (admin access). But `Authenticate()` always sets `UserID: 0` for JWT auth (line 146). Every JWT-authenticated regular user has unrestricted access to all rows. RBAC is non-functional for JWT sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. IDOR — No Ownership Checks in `/api/v1/query`
|
||||||
|
**File:** `pkg/api/server.go:506-548`
|
||||||
|
|
||||||
|
`handleQueryGet`, `handleQueryUpdate`, and `handleQueryDelete` operate on records using only the user-supplied `id` with no `user_id` ownership check. Any authenticated user can read, update, or delete any other user's hooks, accounts, API keys, or sessions.
|
||||||
|
|
||||||
|
```go
|
||||||
|
db.NewUpdate().Model(model).Where("id = ?", req.ID).Exec(...)
|
||||||
|
db.NewDelete().Model(model).Where("id = ?", req.ID).Exec(...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. user_id Spoofing in Create
|
||||||
|
**File:** `pkg/api/server.go:440`
|
||||||
|
|
||||||
|
```go
|
||||||
|
if _, exists := req.Data["user_id"]; !exists {
|
||||||
|
req.Data["user_id"] = userID
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-inject only runs when `user_id` is absent. A user can supply any `user_id` in the request body and create resources owned by another user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Hardcoded Default JWT Secret
|
||||||
|
**File:** `pkg/config/config.go:161`
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg.Server.JWTSecret = "change-me-in-production"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `jwt_secret` is omitted from config, this well-known default is used. Attackers can forge valid JWT tokens for any user ID and role including `admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Unauthenticated Media File Serving
|
||||||
|
**File:** `pkg/api/server.go:200`
|
||||||
|
|
||||||
|
```go
|
||||||
|
router.PathPrefix("/api/media/").HandlerFunc(h.ServeMedia)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServeMedia` is not wrapped with `h.Auth()`. All WhatsApp media (images, documents, audio, video) is publicly accessible without credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HIGH
|
||||||
|
|
||||||
|
### 6. No Token Invalidation on Logout
|
||||||
|
**File:** `pkg/api/security.go:129-133`
|
||||||
|
|
||||||
|
`Logout()` is a no-op. Tokens remain valid for 24 hours after logout. The `sessions` table exists but is never used to validate or invalidate tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Unauthenticated QR Code Access
|
||||||
|
**File:** `pkg/api/server.go:202`
|
||||||
|
|
||||||
|
```go
|
||||||
|
router.PathPrefix("/api/qr/").HandlerFunc(h.ServeQRCode)
|
||||||
|
```
|
||||||
|
|
||||||
|
Any unauthenticated party who can reach the server can retrieve QR codes for any `account_id` and link their own device to a WhatsApp account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. No Rate Limiting on Login Endpoint
|
||||||
|
**File:** `pkg/api/server.go:259`
|
||||||
|
|
||||||
|
`/api/v1/auth/login` has no rate limiting or account lockout. Brute-force attacks are unrestricted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. CORS Misconfiguration
|
||||||
|
**File:** `pkg/api/server.go:663-667`
|
||||||
|
|
||||||
|
```go
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
```
|
||||||
|
|
||||||
|
`Allow-Origin: *` with `Allow-Credentials: true` is an invalid combination (browsers reject it). The wildcard allows any origin to read API responses. If origin is later restricted, the credentials flag enables CSRF via CORS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. No Request Body Size Limit
|
||||||
|
**File:** All handlers
|
||||||
|
|
||||||
|
All handlers use `json.NewDecoder(r.Body).Decode()` without `http.MaxBytesReader`. Arbitrarily large request bodies can cause memory exhaustion.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MEDIUM
|
||||||
|
|
||||||
|
### 11. Internal Database Errors Leaked to Client
|
||||||
|
**File:** `pkg/api/server.go:382, 499, 529, 543`
|
||||||
|
|
||||||
|
```go
|
||||||
|
http.Error(w, fmt.Sprintf("Query failed: %v", err), ...)
|
||||||
|
http.Error(w, fmt.Sprintf("Create failed: %v", err), ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Raw database errors including table names, column names, and constraint violations are returned to clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Config File Saved World-Readable
|
||||||
|
**File:** `pkg/config/config.go:229`
|
||||||
|
|
||||||
|
```go
|
||||||
|
os.WriteFile(path, data, 0644)
|
||||||
|
```
|
||||||
|
|
||||||
|
Config contains secrets (JWT secret, DB password, API tokens, MQTT credentials). Permissions should be `0600`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Session Path Traversal Potential
|
||||||
|
**File:** `pkg/api/server.go:461`
|
||||||
|
|
||||||
|
```go
|
||||||
|
req.Data["session_path"] = fmt.Sprintf("./sessions/%s", sessionID)
|
||||||
|
```
|
||||||
|
|
||||||
|
`sessionID` is derived from user-supplied `account_id`. A value containing `../` could point the session path outside the sessions directory. `account_id` is not sanitized.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. Phase 1 Config Password Stored Plaintext
|
||||||
|
**File:** `pkg/config/config.go:26-28`
|
||||||
|
|
||||||
|
`username` and `password` in `ServerConfig` are stored as plaintext strings and compared directly. Config file leakage equals credential leakage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOW
|
||||||
|
|
||||||
|
### 15. JWT Stored in localStorage
|
||||||
|
**File:** `web/src/lib/api.ts:61`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
localStorage.setItem("auth_token", token);
|
||||||
|
```
|
||||||
|
|
||||||
|
Accessible to any JavaScript on the page. Any XSS vulnerability results in full token theft. Prefer `httpOnly` cookies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. User Object Deserialized from localStorage Without Validation
|
||||||
|
**File:** `web/src/lib/api.ts:92-93`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
return userStr ? JSON.parse(userStr) : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsed and trusted without server-side revalidation. A tampered value causes the UI to display incorrect role/permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| # | Severity | Issue | Location |
|
||||||
|
|---|----------|-------|----------|
|
||||||
|
| 1 | Critical | JWT users get admin row access | `security.go:236` |
|
||||||
|
| 2 | Critical | IDOR in query update/delete/get | `server.go:506-548` |
|
||||||
|
| 3 | Critical | user_id spoofing in create | `server.go:440` |
|
||||||
|
| 4 | Critical | Default JWT secret in code | `config.go:161` |
|
||||||
|
| 5 | Critical | Media served without auth | `server.go:200` |
|
||||||
|
| 6 | High | No token revocation on logout | `security.go:129` |
|
||||||
|
| 7 | High | Unauthenticated QR code access | `server.go:202` |
|
||||||
|
| 8 | High | No login rate limiting | `server.go:259` |
|
||||||
|
| 9 | High | CORS misconfiguration | `server.go:663` |
|
||||||
|
| 10 | High | No request body size limit | all handlers |
|
||||||
|
| 11 | Medium | DB errors leaked to client | `server.go:382,499,529` |
|
||||||
|
| 12 | Medium | Config file world-readable (0644) | `config.go:229` |
|
||||||
|
| 13 | Medium | Session path traversal potential | `server.go:461` |
|
||||||
|
| 14 | Medium | Plaintext Phase 1 password in config | `config.go:26` |
|
||||||
|
| 15 | Low | JWT in localStorage | `api.ts:61` |
|
||||||
|
| 16 | Low | Unvalidated localStorage user object | `api.ts:92` |
|
||||||
@@ -200,14 +200,23 @@ class ApiClient {
|
|||||||
await this.client.delete(`/api/v1/whatsapp_accounts/${id}`);
|
await this.client.delete(`/api/v1/whatsapp_accounts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event Logs API
|
// Event Logs API — uses RestHeadSpec native headers for server-side pagination/sorting
|
||||||
async getEventLogs(params?: {
|
async getEventLogs(params?: {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}): Promise<EventLog[]> {
|
sort?: string;
|
||||||
const { data } = await this.client.get<EventLog[]>("/api/v1/event_logs", {
|
search?: string;
|
||||||
params,
|
}): Promise<{ data: EventLog[]; meta: { total: number; limit: number; offset: number } }> {
|
||||||
});
|
const headers: Record<string, string> = { 'X-DetailApi': 'true' };
|
||||||
|
if (params?.sort) headers['X-Sort'] = params.sort;
|
||||||
|
if (params?.limit) headers['X-Limit'] = String(params.limit);
|
||||||
|
if (params?.offset !== undefined) headers['X-Offset'] = String(params.offset);
|
||||||
|
if (params?.search) headers['X-SearchOp-Like-EventType'] = params.search;
|
||||||
|
|
||||||
|
const { data } = await this.client.get<{ data: EventLog[]; meta: { total: number; limit: number; offset: number } }>(
|
||||||
|
'/api/v1/event_logs',
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export interface QueryRequest {
|
|||||||
id?: string;
|
id?: string;
|
||||||
data?: Record<string, any>;
|
data?: Record<string, any>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
|
search?: string;
|
||||||
|
search_columns?: string[];
|
||||||
|
order_by?: string;
|
||||||
|
order_dir?: 'ASC' | 'DESC';
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function DashboardPage() {
|
|||||||
users: users?.length || 0,
|
users: users?.length || 0,
|
||||||
hooks: hooks?.length || 0,
|
hooks: hooks?.length || 0,
|
||||||
accounts: accounts?.length || 0,
|
accounts: accounts?.length || 0,
|
||||||
eventLogs: eventLogs?.length || 0
|
eventLogs: eventLogs?.meta?.total || 0
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load stats:', err);
|
console.error('Failed to load stats:', err);
|
||||||
|
|||||||
@@ -1,43 +1,57 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
Table,
|
Table,
|
||||||
Badge,
|
Badge,
|
||||||
Group,
|
Group,
|
||||||
Alert,
|
Alert,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Stack,
|
Stack,
|
||||||
TextInput,
|
TextInput,
|
||||||
Select,
|
|
||||||
Pagination,
|
Pagination,
|
||||||
Code,
|
Code,
|
||||||
Tooltip
|
Tooltip
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
|
import { IconAlertCircle, IconFileText, IconSearch } from '@tabler/icons-react';
|
||||||
import { listRecords } from '../lib/query';
|
import { apiClient } from '../lib/api';
|
||||||
import type { EventLog } from '../types';
|
import type { EventLog } from '../types';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
export default function EventLogsPage() {
|
export default function EventLogsPage() {
|
||||||
const [logs, setLogs] = useState<EventLog[]>([]);
|
const [logs, setLogs] = useState<EventLog[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [filterType, setFilterType] = useState<string | null>(null);
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 20;
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Debounce search input by 400ms
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLogs();
|
const timer = setTimeout(() => setDebouncedSearch(searchQuery), 400);
|
||||||
}, []);
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
const loadLogs = async () => {
|
// Reset to page 1 on new search
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await listRecords<EventLog>('event_logs');
|
const result = await apiClient.getEventLogs({
|
||||||
setLogs(data || []);
|
sort: '-created_at',
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
offset: (currentPage - 1) * ITEMS_PER_PAGE,
|
||||||
|
search: debouncedSearch || undefined,
|
||||||
|
});
|
||||||
|
setLogs(result.data || []);
|
||||||
|
setTotalCount(result.meta?.total || 0);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to load event logs');
|
setError('Failed to load event logs');
|
||||||
@@ -45,33 +59,13 @@ export default function EventLogsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [currentPage, debouncedSearch]);
|
||||||
|
|
||||||
const getSuccessColor = (success: boolean) => {
|
useEffect(() => {
|
||||||
return success ? 'green' : 'red';
|
loadLogs();
|
||||||
};
|
}, [loadLogs]);
|
||||||
|
|
||||||
// Filter logs
|
const totalPages = Math.ceil(totalCount / ITEMS_PER_PAGE);
|
||||||
const filteredLogs = logs.filter((log) => {
|
|
||||||
const matchesSearch = !searchQuery ||
|
|
||||||
log.event_type.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
log.action?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
log.entity_type?.toLowerCase().includes(searchQuery.toLowerCase());
|
|
||||||
|
|
||||||
const matchesType = !filterType || log.event_type === filterType;
|
|
||||||
|
|
||||||
return matchesSearch && matchesType;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Paginate
|
|
||||||
const totalPages = Math.ceil(filteredLogs.length / itemsPerPage);
|
|
||||||
const paginatedLogs = filteredLogs.slice(
|
|
||||||
(currentPage - 1) * itemsPerPage,
|
|
||||||
currentPage * itemsPerPage
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get unique event types for filter
|
|
||||||
const eventTypes = Array.from(new Set(logs.map(log => log.event_type)));
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -104,29 +98,12 @@ export default function EventLogsPage() {
|
|||||||
|
|
||||||
<Group mb="md">
|
<Group mb="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search logs..."
|
placeholder="Search by event type..."
|
||||||
leftSection={<IconSearch size={16} />}
|
leftSection={<IconSearch size={16} />}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
placeholder="Filter by type"
|
|
||||||
data={[
|
|
||||||
{ value: '', label: 'All Types' },
|
|
||||||
...eventTypes.map(type => ({ value: type, label: type }))
|
|
||||||
]}
|
|
||||||
value={filterType}
|
|
||||||
onChange={(value) => {
|
|
||||||
setFilterType(value);
|
|
||||||
setCurrentPage(1);
|
|
||||||
}}
|
|
||||||
clearable
|
|
||||||
style={{ minWidth: 200 }}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Table highlightOnHover withTableBorder withColumnBorders>
|
<Table highlightOnHover withTableBorder withColumnBorders>
|
||||||
@@ -142,21 +119,21 @@ export default function EventLogsPage() {
|
|||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
{paginatedLogs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={7}>
|
<Table.Td colSpan={7}>
|
||||||
<Center h={200}>
|
<Center h={200}>
|
||||||
<Stack align="center">
|
<Stack align="center">
|
||||||
<IconFileText size={48} stroke={1.5} color="gray" />
|
<IconFileText size={48} stroke={1.5} color="gray" />
|
||||||
<Text c="dimmed">
|
<Text c="dimmed">
|
||||||
{searchQuery || filterType ? 'No matching logs found' : 'No event logs available'}
|
{debouncedSearch ? 'No matching logs found' : 'No event logs available'}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
) : (
|
) : (
|
||||||
paginatedLogs.map((log) => {
|
logs.map((log) => {
|
||||||
let entityDisplay = log.entity_type || '-';
|
let entityDisplay = log.entity_type || '-';
|
||||||
if (log.entity_id) {
|
if (log.entity_id) {
|
||||||
entityDisplay += ` (${log.entity_id.substring(0, 8)}...)`;
|
entityDisplay += ` (${log.entity_id.substring(0, 8)}...)`;
|
||||||
@@ -165,14 +142,10 @@ export default function EventLogsPage() {
|
|||||||
return (
|
return (
|
||||||
<Table.Tr key={log.id}>
|
<Table.Tr key={log.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">
|
<Text size="sm">{new Date(log.created_at).toLocaleString()}</Text>
|
||||||
{new Date(log.created_at).toLocaleString()}
|
|
||||||
</Text>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge variant="light">
|
<Badge variant="light">{log.event_type}</Badge>
|
||||||
{log.event_type}
|
|
||||||
</Badge>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{log.action || '-'}</Text>
|
<Text size="sm">{log.action || '-'}</Text>
|
||||||
@@ -184,7 +157,7 @@ export default function EventLogsPage() {
|
|||||||
<Text size="sm">{log.user_id ? `User ${log.user_id.substring(0, 8)}...` : '-'}</Text>
|
<Text size="sm">{log.user_id ? `User ${log.user_id.substring(0, 8)}...` : '-'}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={getSuccessColor(log.success)} variant="light">
|
<Badge color={log.success ? 'green' : 'red'} variant="light">
|
||||||
{log.success ? 'Success' : 'Failed'}
|
{log.success ? 'Success' : 'Failed'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
@@ -210,22 +183,16 @@ export default function EventLogsPage() {
|
|||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<Group justify="center" mt="xl">
|
<Group justify="center" mt="xl">
|
||||||
<Pagination
|
<Pagination total={totalPages} value={currentPage} onChange={setCurrentPage} />
|
||||||
total={totalPages}
|
|
||||||
value={currentPage}
|
|
||||||
onChange={setCurrentPage}
|
|
||||||
/>
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between" mt="md">
|
<Group justify="space-between" mt="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Showing {paginatedLogs.length} of {filteredLogs.length} logs
|
Showing {logs.length} of {totalCount} logs
|
||||||
</Text>
|
</Text>
|
||||||
{(searchQuery || filterType) && (
|
{debouncedSearch && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">Filtered by: "{debouncedSearch}"</Text>
|
||||||
(filtered from {logs.length} total)
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
Reference in New Issue
Block a user