feat(admin-web): Phase 4 — event-subscriptions, ip-rules, maintenance, sessions, status, gdpr-export pages
This commit is contained in:
parent
aaceba2ee5
commit
e0bc38c365
@ -0,0 +1,255 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Radio, Plus, MoreHorizontal, Trash2, Pause, Play } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface EventSub {
|
||||||
|
id: string;
|
||||||
|
eventType: string;
|
||||||
|
callbackUrl: string;
|
||||||
|
status: string;
|
||||||
|
failureCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, opts?: RequestInit) {
|
||||||
|
const res = await fetch(`/api/event-subscriptions/${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventSubscriptionsPage() {
|
||||||
|
const [subs, setSubs] = useState<EventSub[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newEvent, setNewEvent] = useState('');
|
||||||
|
const [newUrl, setNewUrl] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await apiFetch('list');
|
||||||
|
setSubs(
|
||||||
|
Array.isArray(data?.subscriptions) ? data.subscriptions : Array.isArray(data) ? data : []
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setCreating(true);
|
||||||
|
await apiFetch('create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ eventType: newEvent, callbackUrl: newUrl }),
|
||||||
|
});
|
||||||
|
setCreating(false);
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewEvent('');
|
||||||
|
setNewUrl('');
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm('Delete this subscription?')) return;
|
||||||
|
await apiFetch(`${id}`, { method: 'DELETE' });
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Event Subscriptions</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage DLQ replay and event subscription routing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Add Subscription
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{subs.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Active</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-emerald-600">
|
||||||
|
{subs.filter(s => s.status === 'active').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Failed</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-red-600">
|
||||||
|
{subs.filter(s => s.status === 'disabled').length}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||||
|
) : subs.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
<Radio className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||||
|
No event subscriptions.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Event Type</TableHead>
|
||||||
|
<TableHead>Callback URL</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Failures</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[50px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subs.map(s => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{s.eventType}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs max-w-[250px] truncate">
|
||||||
|
{s.callbackUrl}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
s.status === 'active'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||||
|
: 'bg-red-50 text-red-700 border-0'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{s.failureCount}</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(s.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDelete(s.id)}
|
||||||
|
className="text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Event Subscription</DialogTitle>
|
||||||
|
<DialogDescription>Subscribe to domain events with a callback URL.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Event Type</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. user.created"
|
||||||
|
value={newEvent}
|
||||||
|
onChange={e => setNewEvent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Callback URL</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/callback"
|
||||||
|
value={newUrl}
|
||||||
|
onChange={e => setNewUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !newEvent.trim() || !newUrl.trim()}
|
||||||
|
>
|
||||||
|
{creating ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
dashboards/admin-web/src/app/(dashboard)/gdpr-export/page.tsx
Normal file
180
dashboards/admin-web/src/app/(dashboard)/gdpr-export/page.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Download, Search, FileJson, Clock, CheckCircle2 } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
interface ExportJob {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
status: string;
|
||||||
|
format: string;
|
||||||
|
requestedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
downloadUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GdprExportPage() {
|
||||||
|
const [jobs, setJobs] = useState<ExportJob[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [exportResult, setExportResult] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleLookup() {
|
||||||
|
if (!userId.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/maintenance/gdpr-exports?userId=${encodeURIComponent(userId)}`,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
setJobs(Array.isArray(data?.exports) ? data.exports : Array.isArray(data) ? data : []);
|
||||||
|
} catch {
|
||||||
|
setJobs([]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExport() {
|
||||||
|
if (!userId.trim()) return;
|
||||||
|
setExporting(true);
|
||||||
|
setExportResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/maintenance/gdpr-exports', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: userId.trim(), format: 'json' }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setExportResult('Export job queued successfully.');
|
||||||
|
handleLookup();
|
||||||
|
} else {
|
||||||
|
setExportResult(data.error || 'Failed to queue export.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setExportResult('Error requesting export.');
|
||||||
|
}
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">GDPR Data Export</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Export all user data for GDPR compliance (right of access / portability)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Request User Data Export</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter a user ID or email to export all their data as JSON.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label>User ID or Email</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="user-id or user@example.com"
|
||||||
|
value={userId}
|
||||||
|
onChange={e => setUserId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={handleLookup} disabled={loading || !userId.trim()}>
|
||||||
|
<Search className="mr-2 h-4 w-4" /> Lookup
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleExport} disabled={exporting || !userId.trim()}>
|
||||||
|
<Download className="mr-2 h-4 w-4" /> {exporting ? 'Exporting...' : 'Export Data'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{exportResult && (
|
||||||
|
<p
|
||||||
|
className={`text-sm ${exportResult.includes('success') ? 'text-emerald-600' : 'text-destructive'}`}
|
||||||
|
>
|
||||||
|
{exportResult}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{jobs.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Export History</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{jobs.map(job => (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileJson className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{job.email || job.userId}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Requested {formatDate(job.requestedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
job.status === 'completed'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||||
|
: job.status === 'pending'
|
||||||
|
? 'bg-amber-50 text-amber-700 border-0'
|
||||||
|
: 'bg-gray-50 text-gray-600 border-0'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{job.status}
|
||||||
|
</Badge>
|
||||||
|
{job.status === 'completed' && job.downloadUrl && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={job.downloadUrl} download>
|
||||||
|
<Download className="mr-1 h-3 w-3" /> Download
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && jobs.length === 0 && userId && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground">
|
||||||
|
<Download className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||||
|
No previous exports for this user.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
dashboards/admin-web/src/app/(dashboard)/ip-rules/page.tsx
Normal file
256
dashboards/admin-web/src/app/(dashboard)/ip-rules/page.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { ShieldBan, Plus, Trash2, Clock } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface IpRule {
|
||||||
|
id: string;
|
||||||
|
cidr: string;
|
||||||
|
type: string;
|
||||||
|
reason?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, opts?: RequestInit) {
|
||||||
|
const res = await fetch(`/api/ip-rules/${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IpRulesPage() {
|
||||||
|
const [rules, setRules] = useState<IpRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newCidr, setNewCidr] = useState('');
|
||||||
|
const [newType, setNewType] = useState('deny');
|
||||||
|
const [newReason, setNewReason] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await apiFetch('list');
|
||||||
|
setRules(Array.isArray(data?.rules) ? data.rules : Array.isArray(data) ? data : []);
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
setCreating(true);
|
||||||
|
await apiFetch('create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ cidr: newCidr, type: newType, reason: newReason || undefined }),
|
||||||
|
});
|
||||||
|
setCreating(false);
|
||||||
|
setShowCreate(false);
|
||||||
|
setNewCidr('');
|
||||||
|
setNewType('deny');
|
||||||
|
setNewReason('');
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm('Delete this IP rule?')) return;
|
||||||
|
await apiFetch(`${id}`, { method: 'DELETE' });
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const denyCount = rules.filter(r => r.type === 'deny').length;
|
||||||
|
const allowCount = rules.filter(r => r.type === 'allow').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">IP Rules</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Manage IP allow/deny lists with CIDR matching
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreate(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> Add Rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Total Rules</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{rules.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Allow</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-emerald-600">{allowCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Deny</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{denyCount}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
<ShieldBan className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||||
|
No IP rules configured.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>CIDR</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[50px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rules.map(r => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>
|
||||||
|
<code className="text-sm bg-muted px-1.5 py-0.5 rounded">{r.cidr}</code>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
className={
|
||||||
|
r.type === 'allow'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-0'
|
||||||
|
: 'bg-red-50 text-red-700 border-0'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{r.reason || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{r.expiresAt ? formatDate(r.expiresAt) : 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(r.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => handleDelete(r.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog open={showCreate} onOpenChange={setShowCreate}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add IP Rule</DialogTitle>
|
||||||
|
<DialogDescription>Add an allow or deny rule with CIDR notation.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>CIDR</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 192.168.1.0/24 or 10.0.0.1/32"
|
||||||
|
value={newCidr}
|
||||||
|
onChange={e => setNewCidr(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Type</Label>
|
||||||
|
<Select value={newType} onValueChange={setNewType}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="allow">Allow</SelectItem>
|
||||||
|
<SelectItem value="deny">Deny</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reason (optional)</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Reason for this rule"
|
||||||
|
value={newReason}
|
||||||
|
onChange={e => setNewReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowCreate(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreate} disabled={creating || !newCidr.trim()}>
|
||||||
|
{creating ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
184
dashboards/admin-web/src/app/(dashboard)/maintenance/page.tsx
Normal file
184
dashboards/admin-web/src/app/(dashboard)/maintenance/page.tsx
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Wrench, AlertTriangle, CheckCircle2, Power } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface MaintenanceStatus {
|
||||||
|
mode: string;
|
||||||
|
message?: string;
|
||||||
|
bypassRoles?: string[];
|
||||||
|
bypassIPs?: string[];
|
||||||
|
scheduledAt?: string;
|
||||||
|
endsAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeConfig: Record<string, { label: string; color: string; description: string }> = {
|
||||||
|
off: {
|
||||||
|
label: 'Normal',
|
||||||
|
color: 'bg-emerald-50 text-emerald-700',
|
||||||
|
description: 'All services operational',
|
||||||
|
},
|
||||||
|
read_only: {
|
||||||
|
label: 'Read Only',
|
||||||
|
color: 'bg-amber-50 text-amber-700',
|
||||||
|
description: 'Write operations disabled',
|
||||||
|
},
|
||||||
|
maintenance: {
|
||||||
|
label: 'Maintenance',
|
||||||
|
color: 'bg-orange-50 text-orange-700',
|
||||||
|
description: 'Platform under maintenance',
|
||||||
|
},
|
||||||
|
emergency: {
|
||||||
|
label: 'Emergency',
|
||||||
|
color: 'bg-red-50 text-red-700',
|
||||||
|
description: 'Emergency lockdown active',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function apiFetch(path: string, opts?: RequestInit) {
|
||||||
|
const res = await fetch(`/api/maintenance/${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MaintenancePage() {
|
||||||
|
const [status, setStatus] = useState<MaintenanceStatus>({ mode: 'off' });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [newMode, setNewMode] = useState('off');
|
||||||
|
const [newMessage, setNewMessage] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await apiFetch('status');
|
||||||
|
if (data?.mode) {
|
||||||
|
setStatus(data);
|
||||||
|
setNewMode(data.mode);
|
||||||
|
setNewMessage(data.message || '');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
await apiFetch('mode', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ mode: newMode, message: newMessage || undefined }),
|
||||||
|
});
|
||||||
|
setSaving(false);
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = modeConfig[status.mode] || modeConfig.off;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Maintenance Mode</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Control platform availability: normal, read-only, maintenance, or emergency
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Power className="h-4 w-4" /> Current Status
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Badge className={`${cfg.color} border-0 text-sm px-3 py-1`}>{cfg.label}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">{cfg.description}</span>
|
||||||
|
</div>
|
||||||
|
{status.message && (
|
||||||
|
<div className="mt-3 p-3 bg-muted rounded-lg">
|
||||||
|
<p className="text-sm">{status.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status.mode !== 'off' && status.bypassRoles && status.bypassRoles.length > 0 && (
|
||||||
|
<div className="mt-3 text-xs text-muted-foreground">
|
||||||
|
Bypass roles: {status.bypassRoles.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Change Mode</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Switch between maintenance modes. Admin roles always bypass.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Mode</Label>
|
||||||
|
<Select value={newMode} onValueChange={setNewMode}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="off">Normal (all services active)</SelectItem>
|
||||||
|
<SelectItem value="read_only">Read Only (writes blocked)</SelectItem>
|
||||||
|
<SelectItem value="maintenance">Maintenance (platform unavailable)</SelectItem>
|
||||||
|
<SelectItem value="emergency">Emergency (full lockdown)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Message (shown to users)</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Optional message..."
|
||||||
|
value={newMessage}
|
||||||
|
onChange={e => setNewMessage(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{newMode === 'emergency' && (
|
||||||
|
<div className="flex items-center gap-2 text-red-600 text-sm">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Emergency mode blocks ALL requests except admin bypass.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
variant={newMode === 'emergency' ? 'destructive' : 'default'}
|
||||||
|
>
|
||||||
|
{saving
|
||||||
|
? 'Saving...'
|
||||||
|
: newMode === status.mode
|
||||||
|
? 'Update Message'
|
||||||
|
: `Switch to ${modeConfig[newMode]?.label || newMode}`}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
dashboards/admin-web/src/app/(dashboard)/sessions-admin/page.tsx
Normal file
196
dashboards/admin-web/src/app/(dashboard)/sessions-admin/page.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { MonitorSmartphone, Search, Trash2, Clock, Globe } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
|
||||||
|
interface SessionEntry {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
email?: string;
|
||||||
|
device?: string;
|
||||||
|
ip?: string;
|
||||||
|
location?: string;
|
||||||
|
lastActive: string;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(path: string, opts?: RequestInit) {
|
||||||
|
const res = await fetch(`/api/sessions/${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SessionsAdminPage() {
|
||||||
|
const [sessions, setSessions] = useState<SessionEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const qs = search ? `?search=${encodeURIComponent(search)}` : '';
|
||||||
|
const data = await apiFetch(`list${qs}`);
|
||||||
|
setSessions(Array.isArray(data?.sessions) ? data.sessions : Array.isArray(data) ? data : []);
|
||||||
|
setLoading(false);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
async function handleRevoke(id: string) {
|
||||||
|
if (!confirm('Revoke this session?')) return;
|
||||||
|
await apiFetch(`${id}/revoke`, { method: 'POST' });
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRevokeAll(userId: string) {
|
||||||
|
if (!confirm(`Revoke all sessions for this user?`)) return;
|
||||||
|
await apiFetch(`revoke-all`, { method: 'POST', body: JSON.stringify({ userId }) });
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Active Sessions</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
View and revoke user sessions across all devices
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Active Sessions
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{sessions.length}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||||
|
Unique Users
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{new Set(sessions.map(s => s.userId)).size}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-muted-foreground">Unique IPs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{new Set(sessions.filter(s => s.ip).map(s => s.ip)).size}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search by email or IP..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{loading ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-muted-foreground">
|
||||||
|
<MonitorSmartphone className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
||||||
|
No active sessions.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Device</TableHead>
|
||||||
|
<TableHead>IP / Location</TableHead>
|
||||||
|
<TableHead>Last Active</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead className="w-[80px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sessions.map(s => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="text-sm font-medium">{s.email || s.userId}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{s.device || '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<Globe className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{s.ip || '—'}
|
||||||
|
{s.location && (
|
||||||
|
<span className="text-muted-foreground">· {s.location}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(s.lastActive)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(s.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive text-xs"
|
||||||
|
onClick={() => handleRevoke(s.id)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
dashboards/admin-web/src/app/(dashboard)/status/page.tsx
Normal file
149
dashboards/admin-web/src/app/(dashboard)/status/page.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Activity, CheckCircle2, XCircle, AlertTriangle, RefreshCw, Globe } from 'lucide-react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface ServiceStatus {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
latency?: number;
|
||||||
|
lastChecked: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { color: string; icon: typeof CheckCircle2 }> = {
|
||||||
|
healthy: { color: 'bg-emerald-50 text-emerald-700', icon: CheckCircle2 },
|
||||||
|
degraded: { color: 'bg-amber-50 text-amber-700', icon: AlertTriangle },
|
||||||
|
down: { color: 'bg-red-50 text-red-700', icon: XCircle },
|
||||||
|
unknown: { color: 'bg-gray-50 text-gray-600', icon: AlertTriangle },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusPage() {
|
||||||
|
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const checks = [
|
||||||
|
{
|
||||||
|
name: 'Platform Service',
|
||||||
|
url: process.env.NEXT_PUBLIC_PLATFORM_URL || '/api/settings/kill-switch',
|
||||||
|
},
|
||||||
|
{ name: 'Admin Dashboard', url: '/api/health' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results: ServiceStatus[] = [];
|
||||||
|
for (const svc of checks) {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(svc.url, { signal: AbortSignal.timeout(5000) });
|
||||||
|
results.push({
|
||||||
|
name: svc.name,
|
||||||
|
status: res.ok ? 'healthy' : 'degraded',
|
||||||
|
latency: Date.now() - start,
|
||||||
|
lastChecked: new Date().toISOString(),
|
||||||
|
url: svc.url,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
results.push({
|
||||||
|
name: svc.name,
|
||||||
|
status: 'down',
|
||||||
|
latency: Date.now() - start,
|
||||||
|
lastChecked: new Date().toISOString(),
|
||||||
|
url: svc.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setServices(results);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const healthyCount = services.filter(s => s.status === 'healthy').length;
|
||||||
|
const overallStatus =
|
||||||
|
services.length === 0
|
||||||
|
? 'unknown'
|
||||||
|
: healthyCount === services.length
|
||||||
|
? 'healthy'
|
||||||
|
: healthyCount > 0
|
||||||
|
? 'degraded'
|
||||||
|
: 'down';
|
||||||
|
const overallCfg = statusConfig[overallStatus];
|
||||||
|
const OverallIcon = overallCfg.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Platform Status</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Real-time health check of all platform services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={loadData} disabled={loading}>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<OverallIcon
|
||||||
|
className={`h-12 w-12 ${overallStatus === 'healthy' ? 'text-emerald-500' : overallStatus === 'degraded' ? 'text-amber-500' : 'text-red-500'}`}
|
||||||
|
/>
|
||||||
|
<h2 className="text-xl font-bold">
|
||||||
|
{overallStatus === 'healthy'
|
||||||
|
? 'All Systems Operational'
|
||||||
|
: overallStatus === 'degraded'
|
||||||
|
? 'Partial Degradation'
|
||||||
|
: 'Service Disruption'}
|
||||||
|
</h2>
|
||||||
|
<Badge className={`${overallCfg.color} border-0 text-sm`}>
|
||||||
|
{overallStatus.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{services.map(svc => {
|
||||||
|
const cfg = statusConfig[svc.status] || statusConfig.unknown;
|
||||||
|
const StatusIcon = cfg.icon;
|
||||||
|
return (
|
||||||
|
<Card key={svc.name}>
|
||||||
|
<CardContent className="p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusIcon
|
||||||
|
className={`h-5 w-5 ${svc.status === 'healthy' ? 'text-emerald-500' : svc.status === 'degraded' ? 'text-amber-500' : 'text-red-500'}`}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{svc.name}</p>
|
||||||
|
{svc.url && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">{svc.url}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{svc.latency != null && (
|
||||||
|
<span className="text-xs text-muted-foreground">{svc.latency}ms</span>
|
||||||
|
)}
|
||||||
|
<Badge className={`${cfg.color} border-0`}>{svc.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Event Subscriptions API proxy — forwards to platform-service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
|
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||||
|
|
||||||
|
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
try {
|
||||||
|
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { path } = await params;
|
||||||
|
const targetPath = `/api/event-subscriptions/${path.join('/')}`;
|
||||||
|
const qs = new URL(req.url).search;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||||
|
'x-user-id': caller.id,
|
||||||
|
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||||
|
};
|
||||||
|
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||||
|
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||||
|
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
logError('Event subscriptions proxy error', error);
|
||||||
|
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
43
dashboards/admin-web/src/app/api/ip-rules/[...path]/route.ts
Normal file
43
dashboards/admin-web/src/app/api/ip-rules/[...path]/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* IP Rules API proxy — forwards to platform-service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
|
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||||
|
|
||||||
|
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
try {
|
||||||
|
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { path } = await params;
|
||||||
|
const targetPath = `/api/ip-rules/${path.join('/')}`;
|
||||||
|
const qs = new URL(req.url).search;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||||
|
'x-user-id': caller.id,
|
||||||
|
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||||
|
};
|
||||||
|
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||||
|
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||||
|
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
logError('IP rules proxy error', error);
|
||||||
|
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Maintenance API proxy — forwards to platform-service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
|
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||||
|
|
||||||
|
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
try {
|
||||||
|
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { path } = await params;
|
||||||
|
const targetPath = `/api/maintenance/${path.join('/')}`;
|
||||||
|
const qs = new URL(req.url).search;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||||
|
'x-user-id': caller.id,
|
||||||
|
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||||
|
};
|
||||||
|
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||||
|
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||||
|
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
logError('Maintenance proxy error', error);
|
||||||
|
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
43
dashboards/admin-web/src/app/api/sessions/[...path]/route.ts
Normal file
43
dashboards/admin-web/src/app/api/sessions/[...path]/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Sessions API proxy — forwards to platform-service.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCurrentUser } from '@/lib/auth-server';
|
||||||
|
import { logError } from '@/lib/logger';
|
||||||
|
|
||||||
|
const PLATFORM_URL = process.env.PLATFORM_SERVICE_URL || 'http://localhost:4003';
|
||||||
|
|
||||||
|
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
try {
|
||||||
|
const caller = await getCurrentUser(req.headers.get('authorization'));
|
||||||
|
if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { path } = await params;
|
||||||
|
const targetPath = `/api/sessions/${path.join('/')}`;
|
||||||
|
const qs = new URL(req.url).search;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-request-id': req.headers.get('x-request-id') || crypto.randomUUID(),
|
||||||
|
'x-user-id': caller.id,
|
||||||
|
'x-product-id': req.headers.get('x-product-id') || process.env.PRODUCT_ID || 'lysnrai',
|
||||||
|
};
|
||||||
|
const fetchOptions: RequestInit = { method: req.method, headers };
|
||||||
|
if (['POST', 'PUT', 'PATCH'].includes(req.method)) fetchOptions.body = await req.text();
|
||||||
|
const res = await fetch(`${PLATFORM_URL}${targetPath}${qs}`, fetchOptions);
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
return NextResponse.json(data ?? { error: res.statusText }, { status: res.status });
|
||||||
|
} catch (error) {
|
||||||
|
logError('Sessions proxy error', error);
|
||||||
|
return NextResponse.json({ error: 'Service unavailable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
|
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||||
|
return proxy(req, ctx);
|
||||||
|
}
|
||||||
@ -49,6 +49,12 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Mail,
|
Mail,
|
||||||
Timer,
|
Timer,
|
||||||
|
Radio,
|
||||||
|
ShieldBan,
|
||||||
|
Wrench,
|
||||||
|
MonitorSmartphone,
|
||||||
|
Globe,
|
||||||
|
Download,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
@ -86,6 +92,12 @@ const navItems = [
|
|||||||
{ href: '/marketplace', label: 'Marketplace', icon: Store },
|
{ href: '/marketplace', label: 'Marketplace', icon: Store },
|
||||||
{ href: '/delivery', label: 'Delivery Log', icon: Mail },
|
{ href: '/delivery', label: 'Delivery Log', icon: Mail },
|
||||||
{ href: '/jobs', label: 'Scheduled Jobs', icon: Timer },
|
{ href: '/jobs', label: 'Scheduled Jobs', icon: Timer },
|
||||||
|
{ href: '/event-subscriptions', label: 'Event Subs', icon: Radio },
|
||||||
|
{ href: '/ip-rules', label: 'IP Rules', icon: ShieldBan },
|
||||||
|
{ href: '/maintenance', label: 'Maintenance', icon: Wrench },
|
||||||
|
{ href: '/sessions-admin', label: 'Sessions', icon: MonitorSmartphone },
|
||||||
|
{ href: '/status', label: 'Status', icon: Globe },
|
||||||
|
{ href: '/gdpr-export', label: 'GDPR Export', icon: Download },
|
||||||
{ href: '/experiments', label: 'Experiments', icon: FlaskConical },
|
{ href: '/experiments', label: 'Experiments', icon: FlaskConical },
|
||||||
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
{ href: '/predictive/at-risk', label: 'Predictive', icon: TrendingDown },
|
||||||
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
{ href: '/ops', label: 'Mission Control', icon: Activity },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user