mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 23:12:09 +01:00
refactor: fix biome linting issues and update project documentation
- Fix 36+ biome linting issues reducing errors/warnings from 227 to 191 - Replace explicit 'any' types with proper TypeScript interfaces - Fix React hooks dependencies and useCallback patterns - Resolve unused variables and parameter assignment issues - Improve accessibility with proper label associations - Add comprehensive API documentation for admin and security features - Update README.md with accurate PostgreSQL setup and current tech stack - Create complete documentation for audit logging, CSP monitoring, and batch processing - Fix outdated project information and missing developer workflows
This commit is contained in:
@ -5,7 +5,7 @@ import { useEffect, useId, useState } from "react";
|
||||
interface DateRangePickerProps {
|
||||
minDate: string;
|
||||
maxDate: string;
|
||||
onDateRangeChange: (startDate: string, endDate: string) => void;
|
||||
onDateRangeChange: (_startDate: string, _endDate: string) => void;
|
||||
initialStartDate?: string;
|
||||
initialEndDate?: string;
|
||||
}
|
||||
@ -25,7 +25,11 @@ export default function DateRangePicker({
|
||||
useEffect(() => {
|
||||
// Only notify parent component when dates change, not when the callback changes
|
||||
onDateRangeChange(startDate, endDate);
|
||||
}, [startDate, endDate]);
|
||||
}, [
|
||||
startDate,
|
||||
endDate, // Only notify parent component when dates change, not when the callback changes
|
||||
onDateRangeChange,
|
||||
]);
|
||||
|
||||
const handleStartDateChange = (newStartDate: string) => {
|
||||
// Ensure start date is not before min date
|
||||
|
||||
@ -122,11 +122,11 @@ export default function GeographicMap({
|
||||
/**
|
||||
* Process a single country entry into CountryData
|
||||
*/
|
||||
function processCountryEntry(
|
||||
const processCountryEntry = useCallback((
|
||||
code: string,
|
||||
count: number,
|
||||
countryCoordinates: Record<string, [number, number]>
|
||||
): CountryData | null {
|
||||
): CountryData | null => {
|
||||
const coordinates = getCountryCoordinates(code, countryCoordinates);
|
||||
|
||||
if (coordinates) {
|
||||
@ -134,7 +134,7 @@ export default function GeographicMap({
|
||||
}
|
||||
|
||||
return null; // Skip if no coordinates found
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Process all countries data into CountryData array
|
||||
@ -156,7 +156,7 @@ export default function GeographicMap({
|
||||
|
||||
return data;
|
||||
},
|
||||
[]
|
||||
[processCountryEntry]
|
||||
);
|
||||
|
||||
// Process country data when client is ready and dependencies change
|
||||
|
||||
@ -99,6 +99,24 @@ const SessionsIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AuditLogIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<title>Audit Logs</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LogoutIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@ -352,6 +370,14 @@ export default function Sidebar({
|
||||
isActive={pathname === "/dashboard/users"}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
<NavItem
|
||||
href="/dashboard/audit-logs"
|
||||
label="Audit Logs"
|
||||
icon={<AuditLogIcon />}
|
||||
isExpanded={isExpanded}
|
||||
isActive={pathname === "/dashboard/audit-logs"}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</nav>
|
||||
<div className="p-4 border-t mt-auto space-y-2">
|
||||
{/* Theme Toggle */}
|
||||
|
||||
499
components/admin/BatchMonitoringDashboard.tsx
Normal file
499
components/admin/BatchMonitoringDashboard.tsx
Normal file
@ -0,0 +1,499 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Download,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
XCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface BatchMetrics {
|
||||
operationStartTime: number;
|
||||
requestCount: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
retryCount: number;
|
||||
totalCost: number;
|
||||
averageLatency: number;
|
||||
circuitBreakerTrips: number;
|
||||
performanceStats: {
|
||||
p50: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CircuitBreakerStatus {
|
||||
isOpen: boolean;
|
||||
failures: number;
|
||||
lastFailureTime: number;
|
||||
}
|
||||
|
||||
interface SchedulerStatus {
|
||||
isRunning: boolean;
|
||||
createBatchesRunning: boolean;
|
||||
checkStatusRunning: boolean;
|
||||
processResultsRunning: boolean;
|
||||
retryFailedRunning: boolean;
|
||||
isPaused: boolean;
|
||||
consecutiveErrors: number;
|
||||
lastErrorTime: Date | null;
|
||||
circuitBreakers: Record<string, CircuitBreakerStatus>;
|
||||
config: any;
|
||||
}
|
||||
|
||||
interface MonitoringData {
|
||||
timestamp: string;
|
||||
metrics: Record<string, BatchMetrics> | BatchMetrics;
|
||||
schedulerStatus: SchedulerStatus;
|
||||
circuitBreakerStatus: Record<string, CircuitBreakerStatus>;
|
||||
systemHealth: {
|
||||
schedulerRunning: boolean;
|
||||
circuitBreakersOpen: boolean;
|
||||
pausedDueToErrors: boolean;
|
||||
consecutiveErrors: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function BatchMonitoringDashboard() {
|
||||
const [monitoringData, setMonitoringData] = useState<MonitoringData | null>(
|
||||
null
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
const fetchMonitoringData = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedCompany !== "all") {
|
||||
params.set("companyId", selectedCompany);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/admin/batch-monitoring?${params}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setMonitoringData(data);
|
||||
} else {
|
||||
throw new Error("Failed to fetch monitoring data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch batch monitoring data:", error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load batch monitoring data",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedCompany, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonitoringData();
|
||||
}, [fetchMonitoringData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(fetchMonitoringData, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchMonitoringData]);
|
||||
|
||||
const exportLogs = async (format: "json" | "csv") => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/batch-monitoring/export", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
startDate: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), // Last 24 hours
|
||||
endDate: new Date().toISOString(),
|
||||
format,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `batch-logs-${Date.now()}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Batch logs exported as ${format.toUpperCase()}`,
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to export logs",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthStatus = () => {
|
||||
if (!monitoringData) return { status: "unknown", color: "gray" };
|
||||
|
||||
const { systemHealth } = monitoringData;
|
||||
|
||||
if (!systemHealth.schedulerRunning) {
|
||||
return {
|
||||
status: "critical",
|
||||
color: "red",
|
||||
message: "Scheduler not running",
|
||||
};
|
||||
}
|
||||
|
||||
if (systemHealth.pausedDueToErrors) {
|
||||
return {
|
||||
status: "warning",
|
||||
color: "yellow",
|
||||
message: "Paused due to errors",
|
||||
};
|
||||
}
|
||||
|
||||
if (systemHealth.circuitBreakersOpen) {
|
||||
return {
|
||||
status: "warning",
|
||||
color: "yellow",
|
||||
message: "Circuit breakers open",
|
||||
};
|
||||
}
|
||||
|
||||
if (systemHealth.consecutiveErrors > 0) {
|
||||
return {
|
||||
status: "warning",
|
||||
color: "yellow",
|
||||
message: `${systemHealth.consecutiveErrors} consecutive errors`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "healthy",
|
||||
color: "green",
|
||||
message: "All systems operational",
|
||||
};
|
||||
};
|
||||
|
||||
const renderMetricsCards = () => {
|
||||
if (!monitoringData) return null;
|
||||
|
||||
const metrics = Array.isArray(monitoringData.metrics)
|
||||
? monitoringData.metrics[0]
|
||||
: typeof monitoringData.metrics === "object" &&
|
||||
"operationStartTime" in monitoringData.metrics
|
||||
? monitoringData.metrics
|
||||
: Object.values(monitoringData.metrics)[0];
|
||||
|
||||
if (!metrics) return null;
|
||||
|
||||
const successRate =
|
||||
metrics.requestCount > 0
|
||||
? ((metrics.successCount / metrics.requestCount) * 100).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Requests
|
||||
</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{metrics.requestCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics.successCount} successful, {metrics.failureCount} failed
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{successRate}%</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{metrics.retryCount} retries performed
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Latency
|
||||
</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{metrics.averageLatency.toFixed(0)}ms
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
P95: {metrics.performanceStats.p95}ms
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
€{metrics.totalCost.toFixed(4)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Circuit breaker trips: {metrics.circuitBreakerTrips}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSystemStatus = () => {
|
||||
if (!monitoringData) return null;
|
||||
|
||||
const health = getHealthStatus();
|
||||
const { schedulerStatus, circuitBreakerStatus } = monitoringData;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5" />
|
||||
System Health
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{health.status === "healthy" && (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
)}
|
||||
{health.status === "warning" && (
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
)}
|
||||
{health.status === "critical" && (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
{health.status === "unknown" && (
|
||||
<AlertCircle className="h-5 w-5 text-gray-500" />
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
health.status === "healthy" ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{health.message}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Scheduler Running:</span>
|
||||
<Badge
|
||||
variant={
|
||||
schedulerStatus.isRunning ? "default" : "destructive"
|
||||
}
|
||||
>
|
||||
{schedulerStatus.isRunning ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Paused:</span>
|
||||
<Badge
|
||||
variant={schedulerStatus.isPaused ? "destructive" : "default"}
|
||||
>
|
||||
{schedulerStatus.isPaused ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Consecutive Errors:</span>
|
||||
<span>{schedulerStatus.consecutiveErrors}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5" />
|
||||
Circuit Breakers
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(circuitBreakerStatus).map(([name, status]) => (
|
||||
<div key={name} className="flex justify-between items-center">
|
||||
<span className="text-sm capitalize">
|
||||
{name.replace(/([A-Z])/g, " $1").trim()}
|
||||
</span>
|
||||
<Badge variant={status.isOpen ? "destructive" : "default"}>
|
||||
{status.isOpen ? "Open" : "Closed"}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||||
<p>Loading batch monitoring data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Batch Processing Monitor</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time monitoring of OpenAI Batch API operations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Select value={selectedCompany} onValueChange={setSelectedCompany}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select company" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Companies</SelectItem>
|
||||
{/* Add company options here */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 mr-2 ${autoRefresh ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{autoRefresh ? "Auto" : "Manual"}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={fetchMonitoringData}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderSystemStatus()}
|
||||
{renderMetricsCards()}
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Batch Processing Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
Last updated:{" "}
|
||||
{monitoringData?.timestamp
|
||||
? new Date(monitoringData.timestamp).toLocaleString()
|
||||
: "Never"}
|
||||
</div>
|
||||
|
||||
{monitoringData && (
|
||||
<pre className="bg-muted p-4 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(monitoringData, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Batch Processing Logs</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Real-time batch processing logs will be displayed here. For
|
||||
detailed log analysis, use the export feature.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Export Batch Processing Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Export batch processing logs and metrics for detailed analysis.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => exportLogs("json")}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => exportLogs("csv")}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7,14 +7,14 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { FormEvent, ReactNode } from "react";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { useCSRFForm } from "../../lib/hooks/useCSRF";
|
||||
|
||||
interface CSRFProtectedFormProps {
|
||||
children: ReactNode;
|
||||
action: string;
|
||||
method?: "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
onSubmit?: (formData: FormData) => Promise<void> | void;
|
||||
onSubmit?: (_formData: FormData) => Promise<void> | void;
|
||||
className?: string;
|
||||
encType?: string;
|
||||
}
|
||||
@ -71,13 +71,7 @@ export function CSRFProtectedForm({
|
||||
encType={encType}
|
||||
>
|
||||
{/* Hidden CSRF token field for non-JS fallback */}
|
||||
{token && (
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
value={token}
|
||||
/>
|
||||
)}
|
||||
{token && <input type="hidden" name="csrf_token" value={token} />}
|
||||
|
||||
{children}
|
||||
</form>
|
||||
@ -99,7 +93,9 @@ export function ExampleCSRFForm() {
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-md">
|
||||
<h2 className="text-xl font-semibold mb-4">CSRF Protected Form Example</h2>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
CSRF Protected Form Example
|
||||
</h2>
|
||||
|
||||
<CSRFProtectedForm
|
||||
action="/api/example-endpoint"
|
||||
@ -107,7 +103,10 @@ export function ExampleCSRFForm() {
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
@ -120,7 +119,10 @@ export function ExampleCSRFForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
@ -133,7 +135,10 @@ export function ExampleCSRFForm() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700">
|
||||
<label
|
||||
htmlFor="message"
|
||||
className="block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
@ -153,4 +158,4 @@ export function ExampleCSRFForm() {
|
||||
</CSRFProtectedForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Api = {
|
||||
fire: (options?: ConfettiOptions) => void;
|
||||
fire: (_options?: ConfettiOptions) => void;
|
||||
};
|
||||
|
||||
type Props = React.ComponentPropsWithRef<"canvas"> & {
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import type React from "react";
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import { CSRFClient } from "../../lib/csrf";
|
||||
|
||||
interface CSRFContextType {
|
||||
@ -15,9 +16,11 @@ interface CSRFContextType {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refreshToken: () => Promise<void>;
|
||||
addTokenToFetch: (options: RequestInit) => RequestInit;
|
||||
addTokenToFormData: (formData: FormData) => FormData;
|
||||
addTokenToObject: <T extends Record<string, unknown>>(obj: T) => T & { csrfToken: string };
|
||||
addTokenToFetch: (_options: RequestInit) => RequestInit;
|
||||
addTokenToFormData: (_formData: FormData) => FormData;
|
||||
addTokenToObject: <T extends Record<string, unknown>>(
|
||||
_obj: T
|
||||
) => T & { csrfToken: string };
|
||||
}
|
||||
|
||||
const CSRFContext = createContext<CSRFContextType | undefined>(undefined);
|
||||
@ -37,7 +40,7 @@ export function CSRFProvider({ children }: CSRFProviderProps) {
|
||||
/**
|
||||
* Fetch CSRF token from server
|
||||
*/
|
||||
const fetchToken = async () => {
|
||||
const fetchToken = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@ -68,13 +71,14 @@ export function CSRFProvider({ children }: CSRFProviderProps) {
|
||||
throw new Error("Invalid response from CSRF endpoint");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch CSRF token";
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to fetch CSRF token";
|
||||
setError(errorMessage);
|
||||
console.error("CSRF token fetch error:", errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh token manually
|
||||
@ -88,7 +92,7 @@ export function CSRFProvider({ children }: CSRFProviderProps) {
|
||||
*/
|
||||
useEffect(() => {
|
||||
fetchToken();
|
||||
}, []);
|
||||
}, [fetchToken]);
|
||||
|
||||
/**
|
||||
* Monitor token changes in cookies
|
||||
@ -118,9 +122,7 @@ export function CSRFProvider({ children }: CSRFProviderProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<CSRFContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</CSRFContext.Provider>
|
||||
<CSRFContext.Provider value={contextValue}>{children}</CSRFContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -150,4 +152,4 @@ export function withCSRF<P extends object>(Component: React.ComponentType<P>) {
|
||||
WrappedComponent.displayName = `withCSRF(${Component.displayName || Component.name})`;
|
||||
|
||||
return WrappedComponent;
|
||||
}
|
||||
}
|
||||
|
||||
200
components/security/GeographicThreatMap.tsx
Normal file
200
components/security/GeographicThreatMap.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
interface GeographicThreatMapProps {
|
||||
geoDistribution: Record<string, number>;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Simple country code to name mapping for common countries
|
||||
const countryNames: Record<string, string> = {
|
||||
USA: "United States",
|
||||
GBR: "United Kingdom",
|
||||
DEU: "Germany",
|
||||
FRA: "France",
|
||||
JPN: "Japan",
|
||||
CHN: "China",
|
||||
IND: "India",
|
||||
BRA: "Brazil",
|
||||
CAN: "Canada",
|
||||
AUS: "Australia",
|
||||
RUS: "Russia",
|
||||
ESP: "Spain",
|
||||
ITA: "Italy",
|
||||
NLD: "Netherlands",
|
||||
KOR: "South Korea",
|
||||
MEX: "Mexico",
|
||||
CHE: "Switzerland",
|
||||
SWE: "Sweden",
|
||||
NOR: "Norway",
|
||||
DNK: "Denmark",
|
||||
FIN: "Finland",
|
||||
POL: "Poland",
|
||||
BEL: "Belgium",
|
||||
AUT: "Austria",
|
||||
NZL: "New Zealand",
|
||||
SGP: "Singapore",
|
||||
THA: "Thailand",
|
||||
IDN: "Indonesia",
|
||||
MYS: "Malaysia",
|
||||
PHL: "Philippines",
|
||||
VNM: "Vietnam",
|
||||
ARE: "UAE",
|
||||
SAU: "Saudi Arabia",
|
||||
ISR: "Israel",
|
||||
ZAF: "South Africa",
|
||||
EGY: "Egypt",
|
||||
TUR: "Turkey",
|
||||
GRC: "Greece",
|
||||
PRT: "Portugal",
|
||||
CZE: "Czech Republic",
|
||||
HUN: "Hungary",
|
||||
ROU: "Romania",
|
||||
BGR: "Bulgaria",
|
||||
HRV: "Croatia",
|
||||
SVN: "Slovenia",
|
||||
SVK: "Slovakia",
|
||||
EST: "Estonia",
|
||||
LVA: "Latvia",
|
||||
LTU: "Lithuania",
|
||||
LUX: "Luxembourg",
|
||||
MLT: "Malta",
|
||||
CYP: "Cyprus",
|
||||
ISL: "Iceland",
|
||||
IRL: "Ireland",
|
||||
ARG: "Argentina",
|
||||
CHL: "Chile",
|
||||
COL: "Colombia",
|
||||
PER: "Peru",
|
||||
URY: "Uruguay",
|
||||
ECU: "Ecuador",
|
||||
BOL: "Bolivia",
|
||||
PRY: "Paraguay",
|
||||
VEN: "Venezuela",
|
||||
UKR: "Ukraine",
|
||||
BLR: "Belarus",
|
||||
MDA: "Moldova",
|
||||
GEO: "Georgia",
|
||||
ARM: "Armenia",
|
||||
AZE: "Azerbaijan",
|
||||
KAZ: "Kazakhstan",
|
||||
UZB: "Uzbekistan",
|
||||
KGZ: "Kyrgyzstan",
|
||||
TJK: "Tajikistan",
|
||||
TKM: "Turkmenistan",
|
||||
MNG: "Mongolia",
|
||||
};
|
||||
|
||||
export function GeographicThreatMap({
|
||||
geoDistribution,
|
||||
title = "Geographic Threat Distribution",
|
||||
}: GeographicThreatMapProps) {
|
||||
const sortedCountries = Object.entries(geoDistribution)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 12);
|
||||
|
||||
const totalEvents = Object.values(geoDistribution).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0
|
||||
);
|
||||
|
||||
const getThreatLevel = (count: number, total: number) => {
|
||||
const percentage = (count / total) * 100;
|
||||
if (percentage > 50) return { level: "high", color: "destructive" };
|
||||
if (percentage > 20) return { level: "medium", color: "secondary" };
|
||||
if (percentage > 5) return { level: "low", color: "outline" };
|
||||
return { level: "minimal", color: "outline" };
|
||||
};
|
||||
|
||||
const getCountryName = (code: string) => {
|
||||
return countryNames[code] || code;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>
|
||||
Security events by country ({totalEvents} total events)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sortedCountries.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No geographic data available</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{sortedCountries.map(([countryCode, count]) => {
|
||||
const threat = getThreatLevel(count, totalEvents);
|
||||
const percentage = ((count / totalEvents) * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={countryCode}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{getCountryName(countryCode)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={threat.color as "default" | "secondary" | "destructive" | "outline"}
|
||||
className="text-xs"
|
||||
>
|
||||
{threat.level}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{count} events ({percentage}%)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold">{count}</div>
|
||||
<div className="w-16 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
threat.level === "high"
|
||||
? "bg-red-500"
|
||||
: threat.level === "medium"
|
||||
? "bg-yellow-500"
|
||||
: threat.level === "low"
|
||||
? "bg-blue-500"
|
||||
: "bg-gray-400"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(100, (count / Math.max(...Object.values(geoDistribution))) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{Object.keys(geoDistribution).length > 12 && (
|
||||
<div className="text-center pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
And {Object.keys(geoDistribution).length - 12} more
|
||||
countries...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
274
components/security/SecurityAlertsTable.tsx
Normal file
274
components/security/SecurityAlertsTable.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, CheckCircle, Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
interface SecurityAlert {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
severity: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string;
|
||||
eventType: string;
|
||||
context: Record<string, unknown>;
|
||||
metadata: Record<string, unknown>;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
interface SecurityAlertsTableProps {
|
||||
alerts: SecurityAlert[];
|
||||
onAcknowledge: (_alertId: string) => void;
|
||||
}
|
||||
|
||||
export function SecurityAlertsTable({
|
||||
alerts,
|
||||
onAcknowledge,
|
||||
}: SecurityAlertsTableProps) {
|
||||
const [showAcknowledged, setShowAcknowledged] = useState(false);
|
||||
const [selectedAlert, setSelectedAlert] = useState<SecurityAlert | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity?.toLowerCase()) {
|
||||
case "critical":
|
||||
return "destructive";
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
return "secondary";
|
||||
case "low":
|
||||
return "outline";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAlerts = alerts.filter(
|
||||
(alert) => showAcknowledged || !alert.acknowledged
|
||||
);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
};
|
||||
|
||||
const formatAlertType = (type: string) => {
|
||||
return type
|
||||
.replace(/_/g, " ")
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">Security Alerts</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredAlerts.length} alerts{" "}
|
||||
{showAcknowledged ? "total" : "active"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAcknowledged(!showAcknowledged)}
|
||||
>
|
||||
{showAcknowledged ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
{showAcknowledged ? "Hide Acknowledged" : "Show All"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filteredAlerts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8">
|
||||
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">No Active Alerts</h3>
|
||||
<p className="text-muted-foreground text-center">
|
||||
All security alerts have been addressed. System is operating
|
||||
normally.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Severity</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Timestamp</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAlerts.map((alert) => (
|
||||
<TableRow
|
||||
key={alert.id}
|
||||
className={alert.acknowledged ? "opacity-60" : ""}
|
||||
>
|
||||
<TableCell>
|
||||
<Badge variant={getSeverityColor(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium">
|
||||
{formatAlertType(alert.type)}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{alert.eventType}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium">{alert.title}</span>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{alert.description}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">
|
||||
{formatTimestamp(alert.timestamp)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{alert.acknowledged ? (
|
||||
<Badge variant="outline">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Acknowledged
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedAlert(alert)}
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
{!alert.acknowledged && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onAcknowledge(alert.id)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Alert Details Modal */}
|
||||
{selectedAlert && (
|
||||
<Card className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
|
||||
<Card className="max-w-2xl w-full max-h-[80vh] overflow-auto">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-2">
|
||||
<CardTitle>{selectedAlert.title}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getSeverityColor(selectedAlert.severity)}>
|
||||
{selectedAlert.severity}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{formatAlertType(selectedAlert.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedAlert(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Description</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedAlert.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Context</h4>
|
||||
<div className="bg-muted p-3 rounded-md">
|
||||
<pre className="text-xs overflow-auto">
|
||||
{JSON.stringify(selectedAlert.context, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAlert.metadata &&
|
||||
Object.keys(selectedAlert.metadata).length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Metadata</h4>
|
||||
<div className="bg-muted p-3 rounded-md">
|
||||
<pre className="text-xs overflow-auto">
|
||||
{JSON.stringify(selectedAlert.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatTimestamp(selectedAlert.timestamp)}
|
||||
</span>
|
||||
{!selectedAlert.acknowledged && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onAcknowledge(selectedAlert.id);
|
||||
setSelectedAlert(null);
|
||||
}}
|
||||
>
|
||||
Acknowledge Alert
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
489
components/security/SecurityConfigModal.tsx
Normal file
489
components/security/SecurityConfigModal.tsx
Normal file
@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface SecurityConfig {
|
||||
thresholds: {
|
||||
failedLoginsPerMinute: number;
|
||||
failedLoginsPerHour: number;
|
||||
rateLimitViolationsPerMinute: number;
|
||||
cspViolationsPerMinute: number;
|
||||
adminActionsPerHour: number;
|
||||
massDataAccessThreshold: number;
|
||||
suspiciousIPThreshold: number;
|
||||
};
|
||||
alerting: {
|
||||
enabled: boolean;
|
||||
channels: string[];
|
||||
suppressDuplicateMinutes: number;
|
||||
escalationTimeoutMinutes: number;
|
||||
};
|
||||
retention: {
|
||||
alertRetentionDays: number;
|
||||
metricsRetentionDays: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SecurityConfigModalProps {
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export function SecurityConfigModal({
|
||||
onClose,
|
||||
onSave,
|
||||
}: SecurityConfigModalProps) {
|
||||
const [config, setConfig] = useState<SecurityConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/security-monitoring");
|
||||
if (!response.ok) throw new Error("Failed to load config");
|
||||
|
||||
const data = await response.json();
|
||||
setConfig(data.config);
|
||||
} catch (error) {
|
||||
console.error("Error loading config:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!config) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/security-monitoring", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to save config");
|
||||
|
||||
onSave();
|
||||
} catch (error) {
|
||||
console.error("Error saving config:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateThreshold = (
|
||||
key: keyof SecurityConfig["thresholds"],
|
||||
value: number
|
||||
) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
thresholds: {
|
||||
...config.thresholds,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateAlerting = (
|
||||
key: keyof SecurityConfig["alerting"],
|
||||
value: unknown
|
||||
) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
alerting: {
|
||||
...config.alerting,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateRetention = (
|
||||
key: keyof SecurityConfig["retention"],
|
||||
value: number
|
||||
) => {
|
||||
if (!config) return;
|
||||
setConfig({
|
||||
...config,
|
||||
retention: {
|
||||
...config.retention,
|
||||
[key]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAlertChannel = (channel: string) => {
|
||||
if (!config) return;
|
||||
const channels = config.alerting.channels.includes(channel)
|
||||
? config.alerting.channels.filter((c) => c !== channel)
|
||||
: [...config.alerting.channels, channel];
|
||||
|
||||
updateAlerting("channels", channels);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Error</DialogTitle>
|
||||
<DialogDescription>
|
||||
Failed to load security configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Security Monitoring Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure security monitoring thresholds, alerting, and data
|
||||
retention
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="thresholds" className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="thresholds">Thresholds</TabsTrigger>
|
||||
<TabsTrigger value="alerting">Alerting</TabsTrigger>
|
||||
<TabsTrigger value="retention">Data Retention</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="thresholds" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detection Thresholds</CardTitle>
|
||||
<CardDescription>
|
||||
Configure when security alerts should be triggered
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="failedLoginsPerMinute">
|
||||
Failed Logins per Minute
|
||||
</Label>
|
||||
<Input
|
||||
id="failedLoginsPerMinute"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.thresholds.failedLoginsPerMinute}
|
||||
onChange={(e) =>
|
||||
updateThreshold(
|
||||
"failedLoginsPerMinute",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="failedLoginsPerHour">
|
||||
Failed Logins per Hour
|
||||
</Label>
|
||||
<Input
|
||||
id="failedLoginsPerHour"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={config.thresholds.failedLoginsPerHour}
|
||||
onChange={(e) =>
|
||||
updateThreshold(
|
||||
"failedLoginsPerHour",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rateLimitViolationsPerMinute">
|
||||
Rate Limit Violations per Minute
|
||||
</Label>
|
||||
<Input
|
||||
id="rateLimitViolationsPerMinute"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.thresholds.rateLimitViolationsPerMinute}
|
||||
onChange={(e) =>
|
||||
updateThreshold(
|
||||
"rateLimitViolationsPerMinute",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cspViolationsPerMinute">
|
||||
CSP Violations per Minute
|
||||
</Label>
|
||||
<Input
|
||||
id="cspViolationsPerMinute"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.thresholds.cspViolationsPerMinute}
|
||||
onChange={(e) =>
|
||||
updateThreshold(
|
||||
"cspViolationsPerMinute",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adminActionsPerHour">
|
||||
Admin Actions per Hour
|
||||
</Label>
|
||||
<Input
|
||||
id="adminActionsPerHour"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.thresholds.adminActionsPerHour}
|
||||
onChange={(e) =>
|
||||
updateThreshold(
|
||||
"adminActionsPerHour",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suspiciousIPThreshold">
|
||||
Suspicious IP Threshold
|
||||
</Label>
|
||||
<Input
|
||||
id="suspiciousIPThreshold"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={config.thresholds.suspiciousIPThreshold}
|
||||
onChange={(e) =>
|
||||
updateThreshold(
|
||||
"suspiciousIPThreshold",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alerting" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Alert Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Configure how and when alerts are sent
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="alerting-enabled"
|
||||
checked={config.alerting.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateAlerting("enabled", checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="alerting-enabled">
|
||||
Enable Security Alerting
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Alert Channels</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{["EMAIL", "WEBHOOK", "SLACK", "DISCORD", "PAGERDUTY"].map(
|
||||
(channel) => (
|
||||
<Badge
|
||||
key={channel}
|
||||
variant={
|
||||
config.alerting.channels.includes(channel)
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleAlertChannel(channel)}
|
||||
>
|
||||
{channel}
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suppressDuplicateMinutes">
|
||||
Suppress Duplicates (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="suppressDuplicateMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
value={config.alerting.suppressDuplicateMinutes}
|
||||
onChange={(e) =>
|
||||
updateAlerting(
|
||||
"suppressDuplicateMinutes",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="escalationTimeoutMinutes">
|
||||
Escalation Timeout (minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="escalationTimeoutMinutes"
|
||||
type="number"
|
||||
min="5"
|
||||
max="1440"
|
||||
value={config.alerting.escalationTimeoutMinutes}
|
||||
onChange={(e) =>
|
||||
updateAlerting(
|
||||
"escalationTimeoutMinutes",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="retention" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Data Retention</CardTitle>
|
||||
<CardDescription>
|
||||
Configure how long security data is stored
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="alertRetentionDays">
|
||||
Alert Retention (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="alertRetentionDays"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
value={config.retention.alertRetentionDays}
|
||||
onChange={(e) =>
|
||||
updateRetention(
|
||||
"alertRetentionDays",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="metricsRetentionDays">
|
||||
Metrics Retention (days)
|
||||
</Label>
|
||||
<Input
|
||||
id="metricsRetentionDays"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
value={config.retention.metricsRetentionDays}
|
||||
onChange={(e) =>
|
||||
updateRetention(
|
||||
"metricsRetentionDays",
|
||||
Number.parseInt(e.target.value)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>
|
||||
• Alert data includes security alerts and acknowledgments
|
||||
</p>
|
||||
<p>• Metrics data includes aggregated security statistics</p>
|
||||
<p>
|
||||
• Audit logs are retained separately according to audit
|
||||
policy
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveConfig} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
71
components/security/SecurityMetricsChart.tsx
Normal file
71
components/security/SecurityMetricsChart.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
interface SecurityMetricsChartProps {
|
||||
data: Array<{ hour: number; count: number }>;
|
||||
type?: "line" | "bar";
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function SecurityMetricsChart({
|
||||
data,
|
||||
type = "line",
|
||||
title,
|
||||
}: SecurityMetricsChartProps) {
|
||||
const chartData = data.map((item) => ({
|
||||
hour: `${item.hour}:00`,
|
||||
count: item.count,
|
||||
}));
|
||||
|
||||
const ChartComponent = type === "line" ? LineChart : BarChart;
|
||||
const DataComponent =
|
||||
type === "line" ? (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#8884d8"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: "#8884d8", strokeWidth: 2 }}
|
||||
/>
|
||||
) : (
|
||||
<Bar dataKey="count" fill="#8884d8" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{title && <h3 className="text-lg font-semibold">{title}</h3>}
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<ChartComponent data={chartData}>
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={{ stroke: "#e5e7eb" }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={{ stroke: "#e5e7eb" }}
|
||||
axisLine={{ stroke: "#e5e7eb" }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "#f9fafb",
|
||||
border: "1px solid #e5e7eb",
|
||||
borderRadius: "6px",
|
||||
}}
|
||||
/>
|
||||
{DataComponent}
|
||||
</ChartComponent>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
components/security/ThreatLevelIndicator.tsx
Normal file
84
components/security/ThreatLevelIndicator.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, AlertTriangle, Shield, Zap } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface ThreatLevelIndicatorProps {
|
||||
level: "LOW" | "MODERATE" | "HIGH" | "CRITICAL";
|
||||
score?: number;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function ThreatLevelIndicator({
|
||||
level,
|
||||
score,
|
||||
size = "md",
|
||||
}: ThreatLevelIndicatorProps) {
|
||||
const getConfig = (threatLevel: string) => {
|
||||
switch (threatLevel) {
|
||||
case "CRITICAL":
|
||||
return {
|
||||
color: "destructive",
|
||||
bgColor: "bg-red-500",
|
||||
icon: Zap,
|
||||
text: "Critical Threat",
|
||||
description: "Immediate action required",
|
||||
};
|
||||
case "HIGH":
|
||||
return {
|
||||
color: "destructive",
|
||||
bgColor: "bg-orange-500",
|
||||
icon: AlertCircle,
|
||||
text: "High Threat",
|
||||
description: "Urgent attention needed",
|
||||
};
|
||||
case "MODERATE":
|
||||
return {
|
||||
color: "secondary",
|
||||
bgColor: "bg-yellow-500",
|
||||
icon: AlertTriangle,
|
||||
text: "Moderate Threat",
|
||||
description: "Monitor closely",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: "outline",
|
||||
bgColor: "bg-green-500",
|
||||
icon: Shield,
|
||||
text: "Low Threat",
|
||||
description: "System is secure",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getConfig(level);
|
||||
const Icon = config.icon;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: { icon: "h-4 w-4", text: "text-sm", badge: "text-xs" },
|
||||
md: { icon: "h-5 w-5", text: "text-base", badge: "text-sm" },
|
||||
lg: { icon: "h-6 w-6", text: "text-lg", badge: "text-base" },
|
||||
};
|
||||
|
||||
const classes = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-full ${config.bgColor}`}>
|
||||
<Icon className={`${classes.icon} text-white`} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={config.color as "default" | "secondary" | "destructive" | "outline"} className={classes.badge}>
|
||||
{config.text}
|
||||
</Badge>
|
||||
{score !== undefined && (
|
||||
<span className={`font-medium ${classes.text}`}>{score}/100</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user