- Add SentinelOne API client (lib/services/sentinelone-client.ts) - Paginated fetching for sites, agents, threats - JWT token auth via S1_API_URL / S1_API_TOKEN env vars - Add SentinelOne sync service (lib/services/sentinelone-sync-service.ts) - Full sync: sites, agents, threats into s1_* tables - Sync history tracking with per-entity results - Add DB migration 038: s1_sites, s1_agents, s1_threats, s1_company_mappings, s1_sync_history tables - Add API routes: - POST/GET /api/sentinelone/sync - GET/POST/DELETE /api/sentinelone/company-mappings - GET /api/sentinelone/coverage (fixed Cartesian product bug) - Add UI pages: - /admin/sync/sentinelone — sync admin with history + stats - /sentinelone/coverage — AV coverage report per site - /sentinelone/mappings — map S1 sites to Autotask companies - Wire SentinelOne into admin sync overview card grid - Add SentinelOne Sync to app navigation - Fix docker-compose: remove explicit S1 env var entries that were overwriting env_file values with empty strings
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Card, CardContent, CardDescription, 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';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Shield, Building2, RefreshCw, Search, Save, Trash2, CheckCircle, XCircle, Link2Off } from 'lucide-react';
|
|
import { Company } from '@/lib/types/autotask';
|
|
|
|
interface S1SiteRow {
|
|
id: number | null;
|
|
s1_site_id: string;
|
|
s1_site_name: string;
|
|
company_id: number | null;
|
|
company_name: string | null;
|
|
state: string;
|
|
active_licenses: number;
|
|
health_status: boolean;
|
|
sku: string;
|
|
notes: string | null;
|
|
}
|
|
|
|
const useToast = () => ({
|
|
toast: ({ title, description, variant }: { title: string; description: string; variant?: string }) => {
|
|
if (variant === 'destructive') { console.error(`${title}: ${description}`); alert(`Error: ${description}`); }
|
|
else console.log(`${title}: ${description}`);
|
|
},
|
|
});
|
|
|
|
export default function S1MappingsPage() {
|
|
const [sites, setSites] = useState<S1SiteRow[]>([]);
|
|
const [companies, setCompanies] = useState<Company[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [filter, setFilter] = useState<'all' | 'mapped' | 'unmapped'>('all');
|
|
const { toast } = useToast();
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [mappingsRes, companiesRes] = await Promise.all([
|
|
fetch('/api/sentinelone/company-mappings?includeUnmapped=true'),
|
|
fetch('/api/companies'),
|
|
]);
|
|
if (mappingsRes.ok) setSites((await mappingsRes.json()).mappings ?? []);
|
|
if (companiesRes.ok) setCompanies((await companiesRes.json()).companies ?? []);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchData(); }, []);
|
|
|
|
const handleSync = async () => {
|
|
setSyncing(true);
|
|
try {
|
|
await fetch('/api/sentinelone/sync', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ triggeredBy: 'manual' }),
|
|
});
|
|
await new Promise(r => setTimeout(r, 5000));
|
|
await fetchData();
|
|
toast({ title: 'Done', description: 'SentinelOne synced' });
|
|
} catch {
|
|
toast({ title: 'Error', description: 'Sync failed', variant: 'destructive' });
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
};
|
|
|
|
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 === 'mapped' && s.company_id !== null) ||
|
|
(filter === 'unmapped' && s.company_id === null);
|
|
return matchesSearch && matchesFilter;
|
|
});
|
|
|
|
const stats = {
|
|
total: sites.length,
|
|
mapped: sites.filter(s => s.company_id !== null).length,
|
|
unmapped: sites.filter(s => s.company_id === null).length,
|
|
};
|
|
|
|
return (
|
|
<div className="container mx-auto py-8 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold flex items-center gap-3">
|
|
<Shield className="w-8 h-8 text-purple-600" />
|
|
SentinelOne Site Mappings
|
|
</h1>
|
|
<p className="text-muted-foreground mt-2">Map SentinelOne sites to Autotask companies</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleSync} variant="outline" size="sm" disabled={syncing}>
|
|
{syncing
|
|
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Syncing...</>
|
|
: <><RefreshCw className="w-4 h-4 mr-2" />Sync S1</>}
|
|
</Button>
|
|
<Button onClick={fetchData} variant="outline" size="sm">
|
|
<RefreshCw className="w-4 h-4 mr-2" />Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<Card>
|
|
<CardHeader className="pb-3"><CardTitle className="text-sm text-muted-foreground">Total Sites</CardTitle></CardHeader>
|
|
<CardContent><div className="text-2xl font-bold">{stats.total}</div></CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3"><CardTitle className="text-sm text-muted-foreground flex items-center gap-2"><CheckCircle className="w-4 h-4 text-green-600" />Mapped</CardTitle></CardHeader>
|
|
<CardContent><div className="text-2xl font-bold text-green-600">{stats.mapped}</div></CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="pb-3"><CardTitle className="text-sm text-muted-foreground flex items-center gap-2"><XCircle className="w-4 h-4 text-orange-600" />Unmapped</CardTitle></CardHeader>
|
|
<CardContent><div className="text-2xl font-bold text-orange-600">{stats.unmapped}</div></CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Site Mappings</CardTitle>
|
|
<CardDescription>Each SentinelOne site corresponds to a client. Map them to Autotask companies to enable coverage reporting.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="flex gap-3">
|
|
<div className="relative flex-1">
|
|
<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>
|
|
<Select value={filter} onValueChange={(v: any) => setFilter(v)}>
|
|
<SelectTrigger className="w-[160px]"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Sites</SelectItem>
|
|
<SelectItem value="mapped">Mapped</SelectItem>
|
|
<SelectItem value="unmapped">Unmapped</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="border rounded-lg">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>S1 Site</TableHead>
|
|
<TableHead>SKU / State</TableHead>
|
|
<TableHead>Autotask Company</TableHead>
|
|
<TableHead className="w-[100px]">Status</TableHead>
|
|
<TableHead className="w-[100px] text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground">Loading...</TableCell></TableRow>
|
|
) : filtered.length === 0 ? (
|
|
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground">No sites found</TableCell></TableRow>
|
|
) : filtered.map(site => (
|
|
<SiteMappingRow
|
|
key={site.s1_site_id}
|
|
site={site}
|
|
companies={companies}
|
|
onSaved={fetchData}
|
|
/>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SiteMappingRow({
|
|
site, companies, onSaved,
|
|
}: {
|
|
site: S1SiteRow;
|
|
companies: Company[];
|
|
onSaved: () => void;
|
|
}) {
|
|
const [selectedId, setSelectedId] = useState<number>(site.company_id ?? 0);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
const handleChange = (val: string) => {
|
|
const id = parseInt(val);
|
|
setSelectedId(id);
|
|
setHasChanges(id !== (site.company_id ?? 0));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const company = companies.find(c => c.id === selectedId);
|
|
const res = await fetch('/api/sentinelone/company-mappings', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
s1SiteId: site.s1_site_id,
|
|
s1SiteName: site.s1_site_name,
|
|
companyId: selectedId,
|
|
companyName: company?.companyName ?? null,
|
|
}),
|
|
});
|
|
if (!res.ok) throw new Error('Save failed');
|
|
toast({ title: 'Saved', description: `Mapped ${site.s1_site_name}` });
|
|
setHasChanges(false);
|
|
onSaved();
|
|
} catch {
|
|
toast({ title: 'Error', description: 'Failed to save', variant: 'destructive' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!site.id) return;
|
|
setSaving(true);
|
|
try {
|
|
await fetch(`/api/sentinelone/company-mappings?id=${site.id}`, { method: 'DELETE' });
|
|
toast({ title: 'Deleted', description: `Removed mapping for ${site.s1_site_name}` });
|
|
onSaved();
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<TableRow>
|
|
<TableCell>
|
|
<div className="font-medium">{site.s1_site_name}</div>
|
|
<div className="text-xs text-muted-foreground font-mono">{site.s1_site_id}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline" className="text-xs">{site.sku || '—'}</Badge>
|
|
<div className="text-xs text-muted-foreground mt-1">{site.state}</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Select value={selectedId.toString()} onValueChange={handleChange} disabled={saving}>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue>
|
|
{selectedId === 0 ? 'No mapping' : companies.find(c => c.id === selectedId)?.companyName ?? 'Select...'}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0">No mapping</SelectItem>
|
|
{companies.sort((a, b) => a.companyName.localeCompare(b.companyName)).map(c => (
|
|
<SelectItem key={c.id} value={c.id.toString()}>{c.companyName}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
{site.company_id !== null
|
|
? <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100 text-xs"><CheckCircle className="w-3 h-3 mr-1" />Mapped</Badge>
|
|
: <Badge variant="secondary" className="text-xs"><Link2Off className="w-3 h-3 mr-1" />Unmapped</Badge>}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
{hasChanges && (
|
|
<Button size="sm" onClick={handleSave} disabled={saving || selectedId === 0}>
|
|
{saving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <><Save className="w-3 h-3 mr-1" />Save</>}
|
|
</Button>
|
|
)}
|
|
{site.id && (
|
|
<Button size="sm" variant="ghost" onClick={handleDelete} disabled={saving}>
|
|
<Trash2 className="w-3 h-3" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
}
|