mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
feat(audit): Add audit log API endpoints and UI with signature verification
- Add GET /api/audit endpoint for listing events with filters - Add GET /api/audit/:id/verify endpoint for signature verification - Add AuditLogPanel UI component with filtering and verification - Update docs with audit API documentation - Add localStorage utils for persisting UI state - Update gitignore patterns
This commit is contained in:
parent
7342191075
commit
73c5128a87
11 changed files with 1089 additions and 25 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -17,6 +17,8 @@
|
|||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
active_subs.json
|
||||
charges.json
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
|
|
|
|||
42
docs/API.md
42
docs/API.md
|
|
@ -198,6 +198,48 @@ Returns a new raw token (shown once) and updates stored hashes:
|
|||
"requiresRestart": false,
|
||||
"message": "New API token generated and active immediately! Save this token - it won't be shown again."
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
## 🧾 Audit Log (Enterprise)
|
||||
|
||||
These endpoints require admin access and the `settings:read` scope. In OSS builds, the list endpoint returns an empty set and `persistentLogging: false`.
|
||||
|
||||
### List Audit Events
|
||||
`GET /api/audit?limit=100&event=login&user=admin&success=true&startTime=2024-01-01T00:00:00Z&endTime=2024-01-31T23:59:59Z`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45",
|
||||
"timestamp": "2024-01-12T10:15:30Z",
|
||||
"event": "login",
|
||||
"user": "admin",
|
||||
"ip": "10.0.0.10",
|
||||
"path": "/api/login",
|
||||
"success": true,
|
||||
"details": "Successful login",
|
||||
"signature": "..."
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"persistentLogging": true
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Audit Event Signature
|
||||
`GET /api/audit/<id>/verify`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"available": true,
|
||||
"verified": true,
|
||||
"message": "Event signature verified"
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Validate API Token (Admin)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ Pulse Pro unlocks advanced AI automation features on top of the free Pulse platf
|
|||
|
||||
## What You Get
|
||||
|
||||
### Enterprise Audit Log
|
||||
- Persistent audit trail with SQLite storage and HMAC signing.
|
||||
- Queryable via `/api/audit` and verified per event in the Security → Audit Log UI.
|
||||
- Supports filtering, verification badges, and signature checks for tamper detection.
|
||||
- Configure with `PULSE_AUDIT_SIGNING_KEY`, `PULSE_AUDIT_RETENTION_DAYS`, and `PULSE_AUDIT_CLEANUP_INTERVAL_HOURS`.
|
||||
- API reference: `docs/API.md`.
|
||||
- If no signing key is set, events are stored without signatures and verification will fail.
|
||||
|
||||
### AI Patrol (LLM-Backed)
|
||||
Scheduled background analysis that correlates live state + metrics history to produce actionable findings.
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,21 @@ sudo pulse bootstrap-token
|
|||
- Check if your IP is locked out (wait 15 mins).
|
||||
- If another admin can log in, use `POST /api/security/reset-lockout` to clear the lockout for your username or IP.
|
||||
|
||||
**Audit Log verification shows unsigned events**
|
||||
- **Symptom**: Audit Log entries show “Unsigned” or verification fails in the UI.
|
||||
- **Root cause**: `PULSE_AUDIT_SIGNING_KEY` is not set, so events are stored without signatures.
|
||||
- **Fix**: Set `PULSE_AUDIT_SIGNING_KEY` and restart Pulse Pro. Newly created events will be signed; existing unsigned events remain unsigned.
|
||||
|
||||
**Audit Log is empty**
|
||||
- **Symptom**: Audit Log shows zero events or “Console Logging Only.”
|
||||
- **Root cause**: OSS build uses console logging only, or enterprise audit logging is not enabled.
|
||||
- **Fix**: Use Pulse Pro with enterprise audit logging enabled, then generate new audit events (logins, token creation, password changes).
|
||||
|
||||
**Audit Log verification fails for older events**
|
||||
- **Symptom**: Older events fail verification while newer events pass.
|
||||
- **Root cause**: The signing key changed or was rotated, so signatures no longer match.
|
||||
- **Fix**: Keep `PULSE_AUDIT_SIGNING_KEY` stable. If rotated intentionally, expect older events to fail verification.
|
||||
|
||||
### Monitoring Data
|
||||
|
||||
**VMs show "-" for disk usage**
|
||||
|
|
|
|||
20
frontend-modern/package-lock.json
generated
20
frontend-modern/package-lock.json
generated
|
|
@ -116,6 +116,7 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -489,6 +490,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -512,6 +514,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -1586,6 +1589,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.10.10.tgz",
|
||||
"integrity": "sha512-nGl7gMgsojuaupI5MAK2cFtkndmWWSAPhill/8La3IjujY3vMBamcQFymBsA2ejzxEYJjkOlEQHYgp2jNFkwuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"solid-js": "^1.8.6"
|
||||
}
|
||||
|
|
@ -1755,6 +1759,7 @@
|
|||
"integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -1801,6 +1806,7 @@
|
|||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
|
|
@ -2161,6 +2167,7 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -2472,6 +2479,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -3048,6 +3056,7 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -4035,6 +4044,7 @@
|
|||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
|
|
@ -4065,6 +4075,7 @@
|
|||
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.0.1",
|
||||
"data-urls": "^5.0.0",
|
||||
|
|
@ -4743,6 +4754,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -5226,6 +5238,7 @@
|
|||
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
|
||||
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
|
|
@ -5290,6 +5303,7 @@
|
|||
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
|
||||
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.0",
|
||||
"seroval": "~1.3.0",
|
||||
|
|
@ -5550,6 +5564,7 @@
|
|||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
|
|
@ -5717,6 +5732,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -5848,6 +5864,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -5938,6 +5955,7 @@
|
|||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
|
|
@ -6079,6 +6097,7 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -6112,6 +6131,7 @@
|
|||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
import { createSignal, Show, For, onMount } from 'solid-js';
|
||||
import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, Filter } from 'lucide-solid';
|
||||
import { createSignal, Show, For, onMount, createMemo, onCleanup, createEffect } from 'solid-js';
|
||||
import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, Filter, Info, Play, X } from 'lucide-solid';
|
||||
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
|
||||
import Toggle from '@/components/shared/Toggle';
|
||||
import {
|
||||
createLocalStorageBooleanSignal,
|
||||
createLocalStorageNumberSignal,
|
||||
createLocalStorageStringSignal,
|
||||
STORAGE_KEYS,
|
||||
} from '@/utils/localStorage';
|
||||
import { showSuccess, showWarning, showToast } from '@/utils/toast';
|
||||
|
||||
interface AuditEvent {
|
||||
id: string;
|
||||
|
|
@ -19,21 +28,82 @@ interface AuditResponse {
|
|||
persistentLogging: boolean;
|
||||
}
|
||||
|
||||
interface VerifyResponse {
|
||||
available: boolean;
|
||||
verified?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type VerificationState = {
|
||||
status: 'verified' | 'failed' | 'unavailable' | 'error';
|
||||
message: string;
|
||||
};
|
||||
|
||||
export default function AuditLogPanel() {
|
||||
const [events, setEvents] = createSignal<AuditEvent[]>([]);
|
||||
const [totalEvents, setTotalEvents] = createSignal(0);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [isPersistent, setIsPersistent] = createSignal(false);
|
||||
const [eventFilter, setEventFilter] = createSignal('');
|
||||
const [userFilter, setUserFilter] = createSignal('');
|
||||
const [successFilter, setSuccessFilter] = createSignal<'all' | 'success' | 'failed'>('all');
|
||||
const [eventFilter, setEventFilter] = createLocalStorageStringSignal(
|
||||
STORAGE_KEYS.AUDIT_EVENT_FILTER,
|
||||
'',
|
||||
);
|
||||
const [userFilter, setUserFilter] = createLocalStorageStringSignal(
|
||||
STORAGE_KEYS.AUDIT_USER_FILTER,
|
||||
'',
|
||||
);
|
||||
const [successFilter, setSuccessFilter] = createLocalStorageStringSignal(
|
||||
STORAGE_KEYS.AUDIT_SUCCESS_FILTER,
|
||||
'all',
|
||||
);
|
||||
const [verificationFilter, setVerificationFilter] = createLocalStorageStringSignal(
|
||||
STORAGE_KEYS.AUDIT_VERIFICATION_FILTER,
|
||||
'all',
|
||||
);
|
||||
const allowedVerificationFilters = new Set(['all', 'needs', 'verified', 'failed']);
|
||||
const allowedSuccessFilters = new Set(['all', 'success', 'failed']);
|
||||
const [pageSize, setPageSize] = createLocalStorageNumberSignal(
|
||||
STORAGE_KEYS.AUDIT_PAGE_SIZE,
|
||||
100,
|
||||
);
|
||||
const [pageOffset, setPageOffset] = createLocalStorageNumberSignal(
|
||||
STORAGE_KEYS.AUDIT_PAGE_OFFSET,
|
||||
0,
|
||||
);
|
||||
const [verification, setVerification] = createSignal<Record<string, VerificationState>>({});
|
||||
const [verifying, setVerifying] = createSignal<Record<string, boolean>>({});
|
||||
const [verifyingAll, setVerifyingAll] = createSignal(false);
|
||||
const [verifyAllTotal, setVerifyAllTotal] = createSignal(0);
|
||||
const [verifyAllDone, setVerifyAllDone] = createSignal(0);
|
||||
const [autoVerifyEnabled, setAutoVerifyEnabled] = createLocalStorageBooleanSignal(
|
||||
STORAGE_KEYS.AUDIT_AUTO_VERIFY,
|
||||
true,
|
||||
);
|
||||
const [autoVerifyLimit, setAutoVerifyLimit] = createLocalStorageNumberSignal(
|
||||
STORAGE_KEYS.AUDIT_AUTO_VERIFY_LIMIT,
|
||||
50,
|
||||
);
|
||||
const [pageInput, setPageInput] = createSignal('');
|
||||
const [isMounted, setIsMounted] = createSignal(false);
|
||||
const [cancelVerifyAll, setCancelVerifyAll] = createSignal(false);
|
||||
const [verifyCanceled, setVerifyCanceled] = createSignal(false);
|
||||
const [verifyControllers, setVerifyControllers] = createSignal<Record<string, AbortController>>({});
|
||||
|
||||
const fetchAuditEvents = async (options?: { limit?: number; offset?: number }) => {
|
||||
const limit = options?.limit ?? pageSize();
|
||||
const offset = options?.offset ?? pageOffset();
|
||||
|
||||
const fetchAuditEvents = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setVerification({});
|
||||
setVerifying({});
|
||||
setVerifyAllTotal(0);
|
||||
setVerifyAllDone(0);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '100');
|
||||
params.set('limit', String(limit));
|
||||
params.set('offset', String(Math.max(0, offset)));
|
||||
if (eventFilter()) params.set('event', eventFilter());
|
||||
if (userFilter()) params.set('user', userFilter());
|
||||
if (successFilter() === 'success') params.set('success', 'true');
|
||||
|
|
@ -46,6 +116,23 @@ export default function AuditLogPanel() {
|
|||
const data: AuditResponse = await response.json();
|
||||
setEvents(data.events || []);
|
||||
setIsPersistent(data.persistentLogging);
|
||||
setTotalEvents(data.total ?? 0);
|
||||
if (data.total && offset >= data.total) {
|
||||
const maxOffset = Math.max(0, Math.floor((data.total - 1) / limit) * limit);
|
||||
if (maxOffset !== offset) {
|
||||
setPageOffset(maxOffset);
|
||||
void fetchAuditEvents({ limit, offset: maxOffset });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (data.persistentLogging && autoVerifyEnabled()) {
|
||||
const limit = autoVerifyLimit();
|
||||
if (limit <= 0) return;
|
||||
setTimeout(() => {
|
||||
if (!isMounted()) return;
|
||||
void verifyAllEvents({ limit, showToast: false });
|
||||
}, 0);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
|
|
@ -53,10 +140,160 @@ export default function AuditLogPanel() {
|
|||
}
|
||||
};
|
||||
|
||||
const verifyEvent = async (event: AuditEvent) => {
|
||||
if (!event.signature) return;
|
||||
setVerifying((prev) => ({ ...prev, [event.id]: true }));
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
setVerifyControllers((prev) => ({ ...prev, [event.id]: controller }));
|
||||
if (!isMounted()) {
|
||||
controller.abort();
|
||||
}
|
||||
const response = await fetch(`/api/audit/${event.id}/verify`, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to verify signature: ${response.statusText}`);
|
||||
}
|
||||
const data: VerifyResponse = await response.json();
|
||||
let status: VerificationState['status'] = 'unavailable';
|
||||
if (!data.available) {
|
||||
status = 'unavailable';
|
||||
} else if (data.verified) {
|
||||
status = 'verified';
|
||||
} else {
|
||||
status = 'failed';
|
||||
}
|
||||
setVerification((prev) => ({
|
||||
...prev,
|
||||
[event.id]: { status, message: data.message || '' },
|
||||
}));
|
||||
} catch (err) {
|
||||
if ((err as { name?: string })?.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
setVerification((prev) => ({
|
||||
...prev,
|
||||
[event.id]: {
|
||||
status: 'error',
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
setVerifying((prev) => ({ ...prev, [event.id]: false }));
|
||||
setVerifyControllers((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[event.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAllEvents = async (options?: { limit?: number; showToast?: boolean; resume?: boolean }) => {
|
||||
const limit = options?.limit;
|
||||
let signedEvents = events().filter((event) => event.signature);
|
||||
if (options?.resume) {
|
||||
signedEvents = signedEvents.filter((event) => {
|
||||
const state = verification()[event.id];
|
||||
return !state || state.status === 'failed' || state.status === 'error';
|
||||
});
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
signedEvents = signedEvents.slice(0, Math.max(0, limit));
|
||||
}
|
||||
if (signedEvents.length === 0) return;
|
||||
setVerifyingAll(true);
|
||||
setVerifyCanceled(false);
|
||||
setCancelVerifyAll(false);
|
||||
setVerifyAllTotal(signedEvents.length);
|
||||
setVerifyAllDone(0);
|
||||
for (const event of signedEvents) {
|
||||
if (cancelVerifyAll()) {
|
||||
setVerifyingAll(false);
|
||||
setVerifyCanceled(true);
|
||||
if (options?.showToast) {
|
||||
showToast('info', 'Signature verification canceled');
|
||||
}
|
||||
const controllers = verifyControllers();
|
||||
for (const controller of Object.values(controllers)) {
|
||||
controller.abort();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await verifyEvent(event);
|
||||
setVerifyAllDone((prev) => prev + 1);
|
||||
}
|
||||
setVerifyingAll(false);
|
||||
setVerifyCanceled(false);
|
||||
|
||||
if (options?.showToast) {
|
||||
let verified = 0;
|
||||
let failed = 0;
|
||||
let errors = 0;
|
||||
let unavailable = 0;
|
||||
let unchecked = 0;
|
||||
|
||||
for (const event of signedEvents) {
|
||||
const state = verification()[event.id];
|
||||
if (!state) {
|
||||
unchecked += 1;
|
||||
continue;
|
||||
}
|
||||
switch (state.status) {
|
||||
case 'verified':
|
||||
verified += 1;
|
||||
break;
|
||||
case 'failed':
|
||||
failed += 1;
|
||||
break;
|
||||
case 'error':
|
||||
errors += 1;
|
||||
break;
|
||||
case 'unavailable':
|
||||
unavailable += 1;
|
||||
break;
|
||||
default:
|
||||
unchecked += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0 || errors > 0) {
|
||||
showWarning(
|
||||
'Signature verification completed',
|
||||
`Verified ${verified}, failed ${failed}, errors ${errors}, unavailable ${unavailable}.`,
|
||||
);
|
||||
} else {
|
||||
showSuccess('Signature verification completed', `Verified ${verified} events.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
setIsMounted(true);
|
||||
fetchAuditEvents();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const current = verificationFilter();
|
||||
if (!allowedVerificationFilters.has(current)) {
|
||||
setVerificationFilter('all');
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const current = successFilter();
|
||||
if (!allowedSuccessFilters.has(current)) {
|
||||
setSuccessFilter('all');
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
setIsMounted(false);
|
||||
setCancelVerifyAll(true);
|
||||
const controllers = verifyControllers();
|
||||
for (const controller of Object.values(controllers)) {
|
||||
controller.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const formatTimestamp = (ts: string) => {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString();
|
||||
|
|
@ -78,6 +315,123 @@ export default function AuditLogPanel() {
|
|||
return colors[event] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
|
||||
};
|
||||
|
||||
const hasSignedEvents = () => events().some((event) => event.signature);
|
||||
const hasResumeEvents = () =>
|
||||
events().some((event) => {
|
||||
if (!event.signature) return false;
|
||||
const state = verification()[event.id];
|
||||
return !state || state.status === 'failed' || state.status === 'error';
|
||||
});
|
||||
const resumeCount = () =>
|
||||
events().filter((event) => {
|
||||
if (!event.signature) return false;
|
||||
const state = verification()[event.id];
|
||||
return !state || state.status === 'failed' || state.status === 'error';
|
||||
}).length;
|
||||
const verifyAllLabel = () => {
|
||||
if (!verifyingAll()) return 'Verify All';
|
||||
if (verifyAllTotal() === 0) return 'Verifying…';
|
||||
return `Verifying ${verifyAllDone()} of ${verifyAllTotal()}`;
|
||||
};
|
||||
const hasNextPage = () => events().length === pageSize();
|
||||
const pageNumber = () => Math.floor(pageOffset() / pageSize()) + 1;
|
||||
const totalPages = () => Math.max(1, Math.ceil(totalEvents() / pageSize()));
|
||||
const pageRangeText = () => {
|
||||
if (totalEvents() === 0) return 'Showing 0 of 0';
|
||||
const start = pageOffset() + 1;
|
||||
const end = Math.min(totalEvents(), pageOffset() + events().length);
|
||||
return `Showing ${start}-${end} of ${totalEvents()}`;
|
||||
};
|
||||
|
||||
const verificationSummary = createMemo(() => {
|
||||
const summary = {
|
||||
total: events().length,
|
||||
signed: 0,
|
||||
verified: 0,
|
||||
failed: 0,
|
||||
error: 0,
|
||||
unavailable: 0,
|
||||
unchecked: 0,
|
||||
};
|
||||
|
||||
for (const event of events()) {
|
||||
if (!event.signature) continue;
|
||||
summary.signed += 1;
|
||||
const state = verification()[event.id];
|
||||
if (!state) {
|
||||
summary.unchecked += 1;
|
||||
continue;
|
||||
}
|
||||
switch (state.status) {
|
||||
case 'verified':
|
||||
summary.verified += 1;
|
||||
break;
|
||||
case 'failed':
|
||||
summary.failed += 1;
|
||||
break;
|
||||
case 'error':
|
||||
summary.error += 1;
|
||||
break;
|
||||
case 'unavailable':
|
||||
summary.unavailable += 1;
|
||||
break;
|
||||
default:
|
||||
summary.unchecked += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
});
|
||||
|
||||
const activeFilterCount = () => {
|
||||
let count = 0;
|
||||
if (eventFilter()) count += 1;
|
||||
if (userFilter()) count += 1;
|
||||
if (successFilter() !== 'all') count += 1;
|
||||
if (verificationFilter() !== 'all') count += 1;
|
||||
return count;
|
||||
};
|
||||
|
||||
const activeFilterChips = () => {
|
||||
const chips: { label: string; key: 'event' | 'user' | 'success' | 'verification' }[] = [];
|
||||
if (eventFilter()) chips.push({ label: `Event: ${eventFilter()}`, key: 'event' });
|
||||
if (userFilter()) chips.push({ label: `User: ${userFilter()}`, key: 'user' });
|
||||
if (successFilter() !== 'all') chips.push({ label: `Success: ${successFilter()}`, key: 'success' });
|
||||
if (verificationFilter() !== 'all') chips.push({ label: `Verification: ${verificationFilter()}`, key: 'verification' });
|
||||
return chips;
|
||||
};
|
||||
|
||||
const filteredEvents = createMemo(() => {
|
||||
const filter = verificationFilter();
|
||||
if (filter === 'all') return events();
|
||||
return events().filter((event) => {
|
||||
if (!event.signature) return false;
|
||||
const state = verification()[event.id];
|
||||
if (!state) {
|
||||
return filter === 'needs';
|
||||
}
|
||||
if (filter === 'verified') return state.status === 'verified';
|
||||
if (filter === 'failed') return state.status === 'failed' || state.status === 'error';
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
const getVerificationBadge = (state?: VerificationState) => {
|
||||
if (!state) {
|
||||
return { label: 'Not checked', class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
|
||||
}
|
||||
switch (state.status) {
|
||||
case 'verified':
|
||||
return { label: 'Verified', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' };
|
||||
case 'failed':
|
||||
return { label: 'Failed', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' };
|
||||
case 'error':
|
||||
return { label: 'Error', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' };
|
||||
default:
|
||||
return { label: 'Unavailable', class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
|
|
@ -91,14 +445,55 @@ export default function AuditLogPanel() {
|
|||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchAuditEvents}
|
||||
disabled={loading()}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchAuditEvents()}
|
||||
disabled={loading()}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => verifyAllEvents({ showToast: true })}
|
||||
disabled={!isPersistent() || loading() || verifyingAll() || !hasSignedEvents()}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-indigo-700 dark:text-indigo-200 bg-indigo-50 dark:bg-indigo-900/40 border border-indigo-200 dark:border-indigo-700 rounded-lg hover:bg-indigo-100 dark:hover:bg-indigo-900/60 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle class="w-4 h-4" />
|
||||
{verifyAllLabel()}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCancelVerifyAll(true)}
|
||||
disabled={!verifyingAll()}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => verifyAllEvents({ showToast: true, resume: true })}
|
||||
disabled={verifyingAll() || !hasResumeEvents()}
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
onMouseEnter={(e) => {
|
||||
if (!hasResumeEvents()) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showTooltip(
|
||||
'Retries failed, error, or unchecked signatures on this page.',
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
{ align: 'center', direction: 'up', maxWidth: 260 },
|
||||
);
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
Resume{resumeCount() > 0 ? ` (${resumeCount()})` : ''}
|
||||
</button>
|
||||
<Show when={verifyCanceled() && !verifyingAll()}>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||
Verification canceled
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OSS Notice */}
|
||||
|
|
@ -142,19 +537,164 @@ export default function AuditLogPanel() {
|
|||
/>
|
||||
<select
|
||||
value={successFilter()}
|
||||
onChange={(e) => setSuccessFilter(e.currentTarget.value as any)}
|
||||
onChange={(e) => setSuccessFilter(e.currentTarget.value)}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="success">Success Only</option>
|
||||
<option value="failed">Failed Only</option>
|
||||
</select>
|
||||
<select
|
||||
value={verificationFilter()}
|
||||
onChange={(e) => setVerificationFilter(e.currentTarget.value)}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Verification</option>
|
||||
<option value="needs">Needs Verification</option>
|
||||
<option value="verified">Verified</option>
|
||||
<option value="failed">Failed/Error</option>
|
||||
</select>
|
||||
<select
|
||||
value={String(pageSize())}
|
||||
onChange={(e) => setPageSize(Number(e.currentTarget.value))}
|
||||
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="25">25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
<option value="200">200 / page</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={fetchAuditEvents}
|
||||
onClick={() => {
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
class="px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const hadFilters = activeFilterCount() > 0;
|
||||
setEventFilter('');
|
||||
setUserFilter('');
|
||||
setSuccessFilter('all');
|
||||
setVerificationFilter('all');
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
if (hadFilters) {
|
||||
showSuccess('Audit filters cleared');
|
||||
}
|
||||
}}
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
Clear{activeFilterCount() > 0 ? ` (${activeFilterCount()})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
<Show when={activeFilterCount() > 0}>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<For each={activeFilterChips()}>
|
||||
{(chip) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (chip.key === 'event') setEventFilter('');
|
||||
if (chip.key === 'user') setUserFilter('');
|
||||
if (chip.key === 'success') setSuccessFilter('all');
|
||||
if (chip.key === 'verification') setVerificationFilter('all');
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
title="Click to clear filter"
|
||||
aria-label={`Clear ${chip.label}`}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showTooltip('Click to clear filter', rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (chip.key === 'event') setEventFilter('');
|
||||
if (chip.key === 'user') setUserFilter('');
|
||||
if (chip.key === 'success') setSuccessFilter('all');
|
||||
if (chip.key === 'verification') setVerificationFilter('all');
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
>
|
||||
{chip.label}
|
||||
<X class="w-3 h-3 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Verification Preferences */}
|
||||
<Show when={isPersistent()}>
|
||||
<div class="flex items-center justify-between gap-4 px-1">
|
||||
<Toggle
|
||||
label="Auto-verify signatures"
|
||||
description="Verify signatures after loading audit events."
|
||||
checked={autoVerifyEnabled()}
|
||||
onChange={(e) => setAutoVerifyEnabled(e.currentTarget.checked)}
|
||||
/>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<label class="flex items-center gap-2">
|
||||
<span>Auto-verify limit</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={autoVerifyLimit()}
|
||||
disabled={!autoVerifyEnabled()}
|
||||
onInput={(e) => {
|
||||
const raw = Number(e.currentTarget.value);
|
||||
if (!Number.isFinite(raw)) {
|
||||
setAutoVerifyLimit(0);
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(0, Math.min(500, Math.floor(raw)));
|
||||
setAutoVerifyLimit(clamped);
|
||||
}}
|
||||
class="w-20 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<span class="text-gray-500 dark:text-gray-400">0 disables, max 500</span>
|
||||
<span
|
||||
class="inline-flex items-center text-gray-400 cursor-help"
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showTooltip(
|
||||
'Large verification batches can add load. Use a smaller limit on busy systems.',
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
{ align: 'center', direction: 'up', maxWidth: 260 },
|
||||
);
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<Info class="w-3 h-3" />
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAutoVerifyEnabled(true);
|
||||
setAutoVerifyLimit(50);
|
||||
setPageSize(100);
|
||||
setPageOffset(0);
|
||||
setPageInput('');
|
||||
showSuccess('Audit preferences reset');
|
||||
}}
|
||||
class="ml-2 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Reset preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
|
|
@ -173,7 +713,41 @@ export default function AuditLogPanel() {
|
|||
</Show>
|
||||
|
||||
{/* Events Table */}
|
||||
<Show when={!loading() && isPersistent() && events().length > 0}>
|
||||
<Show when={!loading() && isPersistent() && filteredEvents().length > 0}>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span>Total: {totalEvents()}</span>
|
||||
<span>Signed: {verificationSummary().signed}</span>
|
||||
<span>Verified: {verificationSummary().verified}</span>
|
||||
<span>Failed: {verificationSummary().failed}</span>
|
||||
<span>Errors: {verificationSummary().error}</span>
|
||||
<span>Unavailable: {verificationSummary().unavailable}</span>
|
||||
<span>Unchecked: {verificationSummary().unchecked}</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400 cursor-help"
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const content = [
|
||||
'Unsigned: no signature stored for this event',
|
||||
'Unavailable: verification not supported by the logger',
|
||||
'Not checked: signature exists but has not been verified',
|
||||
'Failed: signature mismatch or tampering detected',
|
||||
'Error: verification request failed',
|
||||
].join('\n');
|
||||
showTooltip(content, rect.left + rect.width / 2, rect.top, {
|
||||
align: 'center',
|
||||
direction: 'up',
|
||||
maxWidth: 320,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<Info class="w-3 h-3" />
|
||||
Legend
|
||||
</span>
|
||||
<span class="ml-auto text-gray-500 dark:text-gray-400">
|
||||
{pageRangeText()} • Page {pageNumber()} of {totalPages()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
|
|
@ -184,11 +758,11 @@ export default function AuditLogPanel() {
|
|||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">IP</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Details</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Signed</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Verification</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<For each={events()}>
|
||||
<For each={filteredEvents()}>
|
||||
{(event) => (
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
|
|
@ -212,8 +786,34 @@ export default function AuditLogPanel() {
|
|||
{event.details || '-'}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<Show when={event.signature} fallback={<span class="text-gray-400">-</span>}>
|
||||
<CheckCircle class="w-4 h-4 text-green-500" />
|
||||
<Show
|
||||
when={event.signature}
|
||||
fallback={<span class="text-gray-400">Unsigned</span>}
|
||||
>
|
||||
{(() => {
|
||||
const badge = getVerificationBadge(verification()[event.id]);
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<Show
|
||||
when={verifying()[event.id]}
|
||||
fallback={
|
||||
<span class={`px-2 py-0.5 text-xs font-medium rounded-full ${badge.class}`}>
|
||||
{badge.label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Verifying…</span>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => verifyEvent(event)}
|
||||
disabled={verifying()[event.id]}
|
||||
class="text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -225,10 +825,98 @@ export default function AuditLogPanel() {
|
|||
</Show>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={!loading() && isPersistent() && events().length === 0}>
|
||||
<Show when={!loading() && isPersistent() && filteredEvents().length === 0}>
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<Shield class="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No audit events found</p>
|
||||
<Show when={verificationFilter() !== 'all'}>
|
||||
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
Try clearing the verification filter.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Pagination */}
|
||||
<Show when={!loading() && isPersistent()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
|
||||
<label class="flex items-center gap-2">
|
||||
<span>Page</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={pageInput()}
|
||||
onInput={(e) => setPageInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const parsed = Number(pageInput());
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const clamped = Math.max(1, Math.min(totalPages(), Math.floor(parsed)));
|
||||
const nextOffset = (clamped - 1) * pageSize();
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
class="w-16 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
const parsed = Number(pageInput());
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const clamped = Math.max(1, Math.min(totalPages(), Math.floor(parsed)));
|
||||
const nextOffset = (clamped - 1) * pageSize();
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
class="px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
disabled={pageOffset() === 0}
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextOffset = Math.max(0, pageOffset() - pageSize());
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
disabled={pageOffset() === 0}
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextOffset = pageOffset() + pageSize();
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
disabled={!hasNextPage()}
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const lastOffset = Math.max(0, (totalPages() - 1) * pageSize());
|
||||
setPageOffset(lastOffset);
|
||||
void fetchAuditEvents({ offset: lastOffset });
|
||||
}}
|
||||
disabled={!hasNextPage()}
|
||||
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,43 @@ export function createLocalStorageBooleanSignal(
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a number signal that syncs with localStorage
|
||||
* @param key - The localStorage key
|
||||
* @param defaultValue - Default value if nothing in localStorage
|
||||
*/
|
||||
export function createLocalStorageNumberSignal(
|
||||
key: string,
|
||||
defaultValue: number = 0,
|
||||
): Signal<number> {
|
||||
return createLocalStorageSignal(
|
||||
key,
|
||||
defaultValue,
|
||||
(val) => {
|
||||
const parsed = Number(val);
|
||||
return Number.isFinite(parsed) ? parsed : defaultValue;
|
||||
},
|
||||
(val) => String(val),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a string signal that syncs with localStorage
|
||||
* @param key - The localStorage key
|
||||
* @param defaultValue - Default value if nothing in localStorage
|
||||
*/
|
||||
export function createLocalStorageStringSignal(
|
||||
key: string,
|
||||
defaultValue: string = '',
|
||||
): Signal<string> {
|
||||
return createLocalStorageSignal(
|
||||
key,
|
||||
defaultValue,
|
||||
(val) => String(val),
|
||||
(val) => String(val),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a number signal that syncs with localStorage
|
||||
* @param key - The localStorage key
|
||||
|
|
@ -126,4 +163,14 @@ export const STORAGE_KEYS = {
|
|||
|
||||
// Feature discovery
|
||||
DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips',
|
||||
|
||||
// Audit log
|
||||
AUDIT_AUTO_VERIFY: 'pulse-audit-auto-verify',
|
||||
AUDIT_AUTO_VERIFY_LIMIT: 'pulse-audit-auto-verify-limit',
|
||||
AUDIT_PAGE_SIZE: 'pulse-audit-page-size',
|
||||
AUDIT_PAGE_OFFSET: 'pulse-audit-page-offset',
|
||||
AUDIT_VERIFICATION_FILTER: 'pulse-audit-verification-filter',
|
||||
AUDIT_EVENT_FILTER: 'pulse-audit-event-filter',
|
||||
AUDIT_USER_FILTER: 'pulse-audit-user-filter',
|
||||
AUDIT_SUCCESS_FILTER: 'pulse-audit-success-filter',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -69,18 +69,30 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req
|
|||
filter.Success = &success
|
||||
}
|
||||
|
||||
logger := audit.GetLogger()
|
||||
|
||||
// Query events from the current logger
|
||||
events, err := audit.GetLogger().Query(filter)
|
||||
events, err := logger.Query(filter)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query audit events", nil)
|
||||
return
|
||||
}
|
||||
|
||||
countFilter := filter
|
||||
countFilter.Limit = 0
|
||||
countFilter.Offset = 0
|
||||
|
||||
totalCount, err := logger.Count(countFilter)
|
||||
if err != nil {
|
||||
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to count audit events", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// For OSS (ConsoleLogger), events will be empty
|
||||
// Return a response indicating the feature status
|
||||
response := map[string]interface{}{
|
||||
"events": events,
|
||||
"total": len(events),
|
||||
"total": totalCount,
|
||||
"persistentLogging": len(events) > 0 || isPersistentLogger(),
|
||||
}
|
||||
|
||||
|
|
|
|||
209
internal/api/audit_handlers_test.go
Normal file
209
internal/api/audit_handlers_test.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
|
||||
)
|
||||
|
||||
type verifyResponse struct {
|
||||
Available bool `json:"available"`
|
||||
Verified bool `json:"verified"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type testAuditLogger struct {
|
||||
events []audit.Event
|
||||
verifyResult bool
|
||||
queryErr error
|
||||
}
|
||||
|
||||
func (l *testAuditLogger) Log(event audit.Event) error {
|
||||
l.events = append(l.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *testAuditLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) {
|
||||
if l.queryErr != nil {
|
||||
return nil, l.queryErr
|
||||
}
|
||||
if filter.ID != "" {
|
||||
for _, event := range l.events {
|
||||
if event.ID == filter.ID {
|
||||
return []audit.Event{event}, nil
|
||||
}
|
||||
}
|
||||
return []audit.Event{}, nil
|
||||
}
|
||||
return l.events, nil
|
||||
}
|
||||
|
||||
func (l *testAuditLogger) Count(filter audit.QueryFilter) (int, error) {
|
||||
events, err := l.Query(filter)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(events), nil
|
||||
}
|
||||
|
||||
func (l *testAuditLogger) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *testAuditLogger) VerifySignature(event audit.Event) bool {
|
||||
return l.verifyResult
|
||||
}
|
||||
|
||||
type testAuditLoggerNoVerify struct {
|
||||
events []audit.Event
|
||||
}
|
||||
|
||||
func (l *testAuditLoggerNoVerify) Log(event audit.Event) error {
|
||||
l.events = append(l.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *testAuditLoggerNoVerify) Query(filter audit.QueryFilter) ([]audit.Event, error) {
|
||||
return l.events, nil
|
||||
}
|
||||
|
||||
func (l *testAuditLoggerNoVerify) Count(filter audit.QueryFilter) (int, error) {
|
||||
return len(l.events), nil
|
||||
}
|
||||
|
||||
func (l *testAuditLoggerNoVerify) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setAuditLogger(t *testing.T, logger audit.Logger) {
|
||||
prev := audit.GetLogger()
|
||||
audit.SetLogger(logger)
|
||||
t.Cleanup(func() {
|
||||
audit.SetLogger(prev)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleVerifyAuditEvent_InvalidPath(t *testing.T) {
|
||||
handler := NewAuditHandlers()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/audit/verify", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleVerifyAuditEvent(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyAuditEvent_NotPersistent(t *testing.T) {
|
||||
setAuditLogger(t, audit.NewConsoleLogger())
|
||||
handler := NewAuditHandlers()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleVerifyAuditEvent(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
var resp verifyResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if resp.Available {
|
||||
t.Fatalf("expected available to be false")
|
||||
}
|
||||
if resp.Message == "" {
|
||||
t.Fatalf("expected message to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyAuditEvent_NoVerifier(t *testing.T) {
|
||||
setAuditLogger(t, &testAuditLoggerNoVerify{})
|
||||
handler := NewAuditHandlers()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleVerifyAuditEvent(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNotImplemented, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyAuditEvent_NotFound(t *testing.T) {
|
||||
setAuditLogger(t, &testAuditLogger{})
|
||||
handler := NewAuditHandlers()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleVerifyAuditEvent(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyAuditEvent_Verified(t *testing.T) {
|
||||
setAuditLogger(t, &testAuditLogger{
|
||||
events: []audit.Event{{ID: "abc"}},
|
||||
verifyResult: true,
|
||||
})
|
||||
handler := NewAuditHandlers()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleVerifyAuditEvent(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
var resp verifyResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if !resp.Available {
|
||||
t.Fatalf("expected available to be true")
|
||||
}
|
||||
if !resp.Verified {
|
||||
t.Fatalf("expected verified to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleVerifyAuditEvent_Failed(t *testing.T) {
|
||||
setAuditLogger(t, &testAuditLogger{
|
||||
events: []audit.Event{{ID: "abc"}},
|
||||
verifyResult: false,
|
||||
})
|
||||
handler := NewAuditHandlers()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleVerifyAuditEvent(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
var resp verifyResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
if !resp.Available {
|
||||
t.Fatalf("expected available to be true")
|
||||
}
|
||||
if resp.Verified {
|
||||
t.Fatalf("expected verified to be false")
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,9 @@ type Logger interface {
|
|||
// Query retrieves audit events matching the filter (optional, may return empty for console logger)
|
||||
Query(filter QueryFilter) ([]Event, error)
|
||||
|
||||
// Count returns the number of audit events matching the filter
|
||||
Count(filter QueryFilter) (int, error)
|
||||
|
||||
// Close releases any resources held by the logger
|
||||
Close() error
|
||||
}
|
||||
|
|
@ -150,6 +153,11 @@ func (c *ConsoleLogger) Query(filter QueryFilter) ([]Event, error) {
|
|||
return []Event{}, nil
|
||||
}
|
||||
|
||||
// Count returns zero for the console logger.
|
||||
func (c *ConsoleLogger) Count(filter QueryFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Close is a no-op for the console logger.
|
||||
func (c *ConsoleLogger) Close() error {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -58,6 +58,19 @@ func TestConsoleLogger_Query(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConsoleLogger_Count(t *testing.T) {
|
||||
logger := NewConsoleLogger()
|
||||
|
||||
count, err := logger.Count(QueryFilter{})
|
||||
if err != nil {
|
||||
t.Errorf("ConsoleLogger.Count() returned error: %v", err)
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("ConsoleLogger.Count() should return 0, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsoleLogger_Close(t *testing.T) {
|
||||
logger := NewConsoleLogger()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue