Follow-on polish for the nav-design overhaul (#9bfb575).
- /veeam-analysis migrates the bespoke TicketRow + custom pagination to
the new DataTable using getRowCanExpand + renderSubRow. Drops ~85
lines of fragment/colspan markup in favor of the standard pattern.
- PageHeader on the last common stragglers — /settings,
/settings/security, /sentinelone/coverage, /sentinelone/mappings.
Settings is reachable from the new top-bar UserMenu so it had to
match the rest of the visual system.
- /dashboard and /status load with the new Skeleton helpers
(SkeletonRows, SkeletonChart, SkeletonTable) so loading shells now
approximate the post-load layout instead of a single h-NN bar.
- DESIGN.md: closed the straggler PageHeader item; deprioritized the
hard-coded palette audit with a note that ~770 references are mostly
semantic via the documented bg-{hue}-500/15 / text-{hue}-700 recipe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
8.3 KiB
TypeScript
190 lines
8.3 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { PageHeader } from '@/components/navigation/page-header';
|
|
import {
|
|
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
|
} from '@/components/ui/table';
|
|
import { Shield, Monitor, AlertTriangle, RefreshCw, Search, CheckCircle2, XCircle, Link2Off } from 'lucide-react';
|
|
|
|
interface SiteCoverage {
|
|
s1_site_id: string;
|
|
s1_site_name: string;
|
|
state: string;
|
|
sku: string;
|
|
health_status: boolean;
|
|
active_licenses: number;
|
|
company_id: number | null;
|
|
company_name: string | null;
|
|
total_agents: number;
|
|
active_agents: number;
|
|
infected_agents: number;
|
|
outdated_agents: number;
|
|
decommissioned_agents: number;
|
|
total_threats: number;
|
|
active_threats: number;
|
|
last_seen: string | null;
|
|
}
|
|
|
|
export default function S1CoveragePage() {
|
|
const [sites, setSites] = useState<SiteCoverage[]>([]);
|
|
const [summary, setSummary] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [filter, setFilter] = useState<'all' | 'issues' | 'unmapped'>('all');
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/sentinelone/coverage');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setSites(data.sites ?? []);
|
|
setSummary(data.summary ?? null);
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchData(); }, []);
|
|
|
|
const filtered = sites.filter(s => {
|
|
const matchesSearch = !search ||
|
|
s.s1_site_name.toLowerCase().includes(search.toLowerCase()) ||
|
|
(s.company_name || '').toLowerCase().includes(search.toLowerCase());
|
|
const matchesFilter =
|
|
filter === 'all' ||
|
|
(filter === 'issues' && (s.infected_agents > 0 || s.active_threats > 0 || s.outdated_agents > 0)) ||
|
|
(filter === 'unmapped' && !s.company_id);
|
|
return matchesSearch && matchesFilter;
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="SentinelOne coverage"
|
|
description="AV agent coverage and threat status per site"
|
|
breadcrumbs={[{ label: 'SentinelOne' }, { label: 'Coverage' }]}
|
|
accent
|
|
actions={
|
|
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
|
Refresh
|
|
</Button>
|
|
}
|
|
/>
|
|
<div className="container mx-auto px-6 py-6 space-y-6">
|
|
|
|
{/* Summary cards */}
|
|
{summary && (
|
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
|
{[
|
|
{ label: 'Total Sites', value: summary.totalSites, color: 'text-foreground' },
|
|
{ label: 'Mapped Sites', value: summary.mappedSites, color: 'text-blue-500' },
|
|
{ label: 'Total Agents', value: summary.totalAgents, color: 'text-foreground' },
|
|
{ label: 'Infected', value: summary.infectedAgents, color: summary.infectedAgents > 0 ? 'text-red-500' : 'text-green-500' },
|
|
{ label: 'Active Threats', value: summary.activeThreats, color: summary.activeThreats > 0 ? 'text-orange-500' : 'text-green-500' },
|
|
{ label: 'Outdated', value: summary.outdatedAgents, color: summary.outdatedAgents > 0 ? 'text-yellow-500' : 'text-green-500' },
|
|
].map(({ label, value, color }) => (
|
|
<Card key={label}>
|
|
<CardHeader className="pb-2"><CardTitle className="text-xs text-muted-foreground">{label}</CardTitle></CardHeader>
|
|
<CardContent><div className={`text-2xl font-bold ${color}`}>{Number(value).toLocaleString()}</div></CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div className="flex gap-3">
|
|
<div className="relative flex-1 max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
|
<Input className="pl-9" placeholder="Search sites or companies..." value={search} onChange={e => setSearch(e.target.value)} />
|
|
</div>
|
|
{(['all', 'issues', 'unmapped'] as const).map(f => (
|
|
<Button key={f} variant={filter === f ? 'default' : 'outline'} size="sm" onClick={() => setFilter(f)}>
|
|
{f === 'all' ? 'All' : f === 'issues' ? 'Has Issues' : 'Unmapped'}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<Card>
|
|
<CardContent className="p-0">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Site</TableHead>
|
|
<TableHead>Company</TableHead>
|
|
<TableHead>SKU</TableHead>
|
|
<TableHead className="text-right">Agents</TableHead>
|
|
<TableHead className="text-right">Active</TableHead>
|
|
<TableHead className="text-right">Infected</TableHead>
|
|
<TableHead className="text-right">Outdated</TableHead>
|
|
<TableHead className="text-right">Threats</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={9} className="text-center py-8 text-muted-foreground">Loading...</TableCell></TableRow>
|
|
) : filtered.length === 0 ? (
|
|
<TableRow><TableCell colSpan={9} className="text-center py-8 text-muted-foreground">No sites found</TableCell></TableRow>
|
|
) : filtered.map(s => (
|
|
<TableRow key={s.s1_site_id} className={s.infected_agents > 0 || s.active_threats > 0 ? 'bg-red-500/5' : ''}>
|
|
<TableCell>
|
|
<div className="font-medium">{s.s1_site_name}</div>
|
|
<div className="text-xs text-muted-foreground">{s.state}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
{s.company_name
|
|
? <span>{s.company_name}</span>
|
|
: <span className="text-muted-foreground flex items-center gap-1"><Link2Off className="w-3 h-3" />Unmapped</span>}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs">{s.sku || '—'}</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">{s.total_agents}</TableCell>
|
|
<TableCell className="text-right">
|
|
<span className={s.active_agents < s.total_agents ? 'text-yellow-500' : ''}>{s.active_agents}</span>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{s.infected_agents > 0
|
|
? <span className="text-red-500 font-bold">{s.infected_agents}</span>
|
|
: <span className="text-muted-foreground">0</span>}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{s.outdated_agents > 0
|
|
? <span className="text-yellow-500">{s.outdated_agents}</span>
|
|
: <span className="text-muted-foreground">0</span>}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{s.active_threats > 0
|
|
? <span className="text-orange-500 font-bold">{s.active_threats}</span>
|
|
: <span className="text-muted-foreground">0</span>}
|
|
</TableCell>
|
|
<TableCell>
|
|
{s.infected_agents > 0 || s.active_threats > 0
|
|
? <Badge variant="destructive" className="text-xs">Action Needed</Badge>
|
|
: s.outdated_agents > 0
|
|
? <Badge className="text-xs bg-yellow-500/20 text-yellow-700 dark:text-yellow-400">Outdated</Badge>
|
|
: s.total_agents === 0
|
|
? <Badge variant="secondary" className="text-xs">No Agents</Badge>
|
|
: <Badge className="text-xs bg-green-500/20 text-green-700 dark:text-green-400">
|
|
<CheckCircle2 className="w-3 h-3 mr-1" />OK
|
|
</Badge>}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|