feat: Add SentinelOne integration

- 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
This commit is contained in:
lorentz 2026-02-27 05:31:31 -05:00
parent d7c3dc7168
commit ed6c4a8b65
12 changed files with 1637 additions and 7 deletions

View file

@ -16,17 +16,20 @@ interface IntegrationCard {
}
const INTEGRATIONS: IntegrationCard[] = [
{ id: 'autotask', category: 'PSA', product: 'Autotask', description: 'Tickets, companies, contacts, time entries, configuration items', href: '/admin/sync/autotask', logo: '/logos/autotask.ico', color: 'red' },
{ id: 'veeam', category: 'Backup', product: 'Veeam VSPC', description: 'Agent jobs, backup jobs, protected workloads, compliance', href: '/admin/sync/veeam', logo: '/logos/veeam.ico', color: 'green' },
{ id: 'datto-rmm', category: 'RMM', product: 'Datto RMM', description: 'Device monitoring, alerts, patch management, remote access', href: '/admin/sync/datto-rmm', logo: '/logos/datto-rmm.ico', color: 'blue' },
{ id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' },
{ id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' },
{ id: 'autotask', category: 'PSA', product: 'Autotask', description: 'Tickets, companies, contacts, time entries, configuration items', href: '/admin/sync/autotask', logo: '/logos/autotask.ico', color: 'red' },
{ id: 'itglue', category: 'Documentation', product: 'IT Glue', description: 'Organizations, configurations, contacts, passwords, flexible assets, documents, domains', href: '/admin/sync/itglue', logo: '/logos/itglue.ico', color: 'blue' },
{ id: 'veeam', category: 'Backup', product: 'Veeam VSPC', description: 'Agent jobs, backup jobs, protected workloads, compliance', href: '/admin/sync/veeam', logo: '/logos/veeam.ico', color: 'green' },
{ id: 'datto-rmm', category: 'RMM', product: 'Datto RMM', description: 'Device monitoring, alerts, patch management, remote access', href: '/admin/sync/datto-rmm', logo: '/logos/datto-rmm.ico', color: 'orange' },
{ id: 'auvik', category: 'NMS', product: 'Auvik', description: 'Network devices, topology, tenant mappings', href: '/admin/sync/auvik', logo: '/logos/auvik.ico', color: 'purple' },
{ id: 'addigy', category: 'Apple RMM', product: 'Addigy', description: 'macOS/iOS device management, policies, compliance', href: '/admin/sync/addigy', logo: '/logos/addigy.ico', color: 'gray' },
{ id: 'sentinelone', category: 'EDR/AV', product: 'SentinelOne', description: 'Endpoint agents, threat detections, site coverage, AV health', href: '/admin/sync/sentinelone', logo: '/logos/sentinelone.ico', color: 'purple' },
];
const COLOR_MAP: Record<string, { bg: string; border: string }> = {
red: { bg: 'bg-red-500/5', border: 'border-red-500/20' },
green: { bg: 'bg-green-500/5', border: 'border-green-500/20' },
blue: { bg: 'bg-blue-500/5', border: 'border-blue-500/20' },
orange: { bg: 'bg-orange-500/5', border: 'border-orange-500/20' },
purple: { bg: 'bg-purple-500/5', border: 'border-purple-500/20' },
gray: { bg: 'bg-muted/20', border: 'border-border' },
};
@ -48,17 +51,24 @@ export default function SyncOverviewPage() {
const [autotaskSync, setAutotaskSync] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [itglueSyncData, setItglueSyncData] = useState<any>(null);
const [s1SyncData, setS1SyncData] = useState<any>(null);
const fetchAll = async () => {
try {
const [intRes, atRes] = await Promise.all([
const [intRes, atRes, itgRes, s1Res] = await Promise.all([
fetch('/api/integrations/status'),
fetch('/api/sync/last-sync'),
fetch('/api/itglue/sync'),
fetch('/api/sentinelone/sync'),
]);
if (intRes.ok) setStatus(await intRes.json());
if (atRes.ok) {
const d = await atRes.json();
setAutotaskSync(d.lastSync || {});
}
if (itgRes.ok) setItglueSyncData(await itgRes.json());
if (s1Res.ok) setS1SyncData(await s1Res.json());
} catch (e) {
console.error(e);
} finally {
@ -110,6 +120,31 @@ export default function SyncOverviewPage() {
critical: d.openAlerts?.critical ?? 0,
};
}
if (id === 'itglue') {
if (!itglueSyncData) return null;
const h = itglueSyncData.history?.[0];
const c = itglueSyncData.counts ?? {};
return {
lastSync: h?.completed_at ?? null,
organizations: Number(c.organizations ?? 0),
configurations: Number(c.configurations ?? 0),
totalUpserted: h?.total_upserted ?? 0,
status: h?.status ?? null,
};
}
if (id === 'sentinelone') {
if (!s1SyncData) return null;
const h = s1SyncData.history?.[0];
const c = s1SyncData.counts ?? {};
return {
lastSync: h?.completed_at ?? null,
status: h?.status ?? null,
sites: Number(c.sites ?? 0),
agents: Number(c.agents ?? 0),
infected: Number(c.infected ?? 0),
threats: Number(c.threats ?? 0),
};
}
if (id === 'auvik') return { lastSync: status.auvik?.lastSync, configured: status.auvik?.configured };
if (id === 'addigy') return { lastSync: status.addigy?.lastSync, configured: status.addigy?.configured };
return null;
@ -131,6 +166,16 @@ export default function SyncOverviewPage() {
if (summary.openAlerts > 0) return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
if (id === 'itglue') {
if (!summary.lastSync) return <Clock className="w-4 h-4 text-muted-foreground" />;
if (summary.status === 'failed') return <XCircle className="w-4 h-4 text-red-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
if (id === 'sentinelone') {
if (!summary.lastSync) return <Clock className="w-4 h-4 text-muted-foreground" />;
if (summary.infected > 0) return <AlertTriangle className="w-4 h-4 text-red-500" />;
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
}
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
};
@ -243,6 +288,40 @@ export default function SyncOverviewPage() {
)}
</>
)}
{intg.id === 'itglue' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Organizations</span>
<span className="font-medium text-foreground">{(summary as any).organizations?.toLocaleString()}</span>
</div>
<div className="flex justify-between">
<span>Configurations</span>
<span className="font-medium text-foreground">{(summary as any).configurations?.toLocaleString()}</span>
</div>
</>
)}
{intg.id === 'sentinelone' && summary && (
<>
<div className="flex justify-between">
<span>Last sync</span>
<span className="font-medium text-foreground">{fmtDate(summary.lastSync)}</span>
</div>
<div className="flex justify-between">
<span>Sites / Agents</span>
<span className="font-medium text-foreground">{(summary as any).sites} / {(summary as any).agents?.toLocaleString()}</span>
</div>
{(summary as any).infected > 0 && (
<div className="flex justify-between text-red-600">
<span>Infected</span>
<span className="font-medium">{(summary as any).infected}</span>
</div>
)}
</>
)}
{(intg.id === 'auvik' || intg.id === 'addigy') && (
<div className="flex justify-between">
<span>Status</span>

View file

@ -0,0 +1,188 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ArrowLeft, RefreshCw, Play, CheckCircle2, XCircle, Clock,
Shield, Monitor, AlertTriangle, Activity,
} from 'lucide-react';
function fmtDate(d: string | null) {
if (!d) return '—';
return new Date(d).toLocaleString();
}
function fmtDuration(ms: number | null) {
if (!ms) return '—';
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
return `${(ms / 60000).toFixed(1)}m`;
}
export default function SentinelOneSyncPage() {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const fetchData = useCallback(async () => {
try {
const res = await fetch('/api/sentinelone/sync');
if (res.ok) setData(await res.json());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 10000);
return () => clearInterval(interval);
}, [fetchData]);
const triggerSync = async () => {
setSyncing(true);
try {
await fetch('/api/sentinelone/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ triggeredBy: 'manual' }),
});
setTimeout(fetchData, 2000);
} finally {
setSyncing(false);
}
};
const lastSync = data?.history?.[0];
const counts = data?.counts ?? {};
const inProgress = data?.inProgress ?? false;
return (
<div className="container mx-auto py-8 space-y-6 max-w-5xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/sync">
<Button variant="ghost" size="sm"><ArrowLeft className="w-4 h-4 mr-1" />Back</Button>
</Link>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Shield className="w-6 h-6 text-purple-500" />
SentinelOne Sync
</h1>
<p className="text-sm text-muted-foreground">Sites, agents, and threats synced to s1_* tables</p>
</div>
</div>
<Button onClick={triggerSync} disabled={syncing || inProgress}>
{syncing || inProgress
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Syncing...</>
: <><Play className="w-4 h-4 mr-2" />Sync Now</>}
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{[
{ label: 'Sites', value: counts.sites, icon: Shield, color: 'text-purple-500' },
{ label: 'Agents', value: counts.agents, icon: Monitor, color: 'text-blue-500' },
{ label: 'Active', value: counts.active_agents, icon: Activity, color: 'text-green-500' },
{ label: 'Infected', value: counts.infected, icon: AlertTriangle, color: 'text-red-500' },
{ label: 'Threats', value: counts.threats, icon: XCircle, color: 'text-orange-500' },
].map(({ label, value, icon: Icon, color }) => (
<Card key={label}>
<CardHeader className="pb-2">
<CardTitle className="text-xs text-muted-foreground flex items-center gap-1">
<Icon className={`w-3 h-3 ${color}`} />{label}
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? '—' : (Number(value ?? 0)).toLocaleString()}
</div>
</CardContent>
</Card>
))}
</div>
{/* Last sync status */}
{lastSync && (
<Card>
<CardHeader>
<CardTitle className="text-sm">Last Sync</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
{lastSync.status === 'completed'
? <CheckCircle2 className="w-5 h-5 text-green-500" />
: lastSync.status === 'running'
? <RefreshCw className="w-5 h-5 text-blue-500 animate-spin" />
: <XCircle className="w-5 h-5 text-red-500" />}
<div>
<div className="font-medium capitalize">{lastSync.status}</div>
<div className="text-xs text-muted-foreground">
{fmtDate(lastSync.completed_at || lastSync.started_at)} · {fmtDuration(lastSync.duration_ms)} · {lastSync.total_upserted?.toLocaleString()} records
</div>
</div>
</div>
{lastSync.entity_results && (
<div className="grid grid-cols-3 gap-2">
{(Array.isArray(lastSync.entity_results)
? lastSync.entity_results
: JSON.parse(lastSync.entity_results)
).map((e: any) => (
<div key={e.entity} className="flex items-center justify-between text-sm border rounded p-2">
<span className="capitalize">{e.entity}</span>
<div className="flex items-center gap-1">
{e.success
? <Badge variant="secondary">{e.recordsUpserted.toLocaleString()}</Badge>
: <Badge variant="destructive">failed</Badge>}
</div>
</div>
))}
</div>
)}
{lastSync.error_message && (
<div className="text-xs text-red-500 bg-red-500/10 rounded p-2">{lastSync.error_message}</div>
)}
</CardContent>
</Card>
)}
{/* History */}
<Card>
<CardHeader><CardTitle className="text-sm">Sync History</CardTitle></CardHeader>
<CardContent>
<div className="space-y-2">
{(data?.history ?? []).map((h: any) => (
<div key={h.id} className="flex items-center justify-between text-sm border-b pb-2 last:border-0">
<div className="flex items-center gap-2">
{h.status === 'completed' ? <CheckCircle2 className="w-4 h-4 text-green-500" />
: h.status === 'running' ? <RefreshCw className="w-4 h-4 text-blue-500 animate-spin" />
: <XCircle className="w-4 h-4 text-red-500" />}
<span className="text-muted-foreground">{fmtDate(h.started_at)}</span>
</div>
<div className="flex items-center gap-3 text-muted-foreground">
<span>{h.total_upserted?.toLocaleString() ?? 0} records</span>
<span>{fmtDuration(h.duration_ms)}</span>
<Badge variant="outline" className="text-xs">{h.triggered_by}</Badge>
</div>
</div>
))}
{!loading && (data?.history ?? []).length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No sync history yet run a sync to get started</p>
)}
</div>
</CardContent>
</Card>
<div className="flex gap-3">
<Link href="/sentinelone/coverage">
<Button variant="outline"><Shield className="w-4 h-4 mr-2" />Coverage Report</Button>
</Link>
<Link href="/sentinelone/mappings">
<Button variant="outline"><Monitor className="w-4 h-4 mr-2" />Company Mappings</Button>
</Link>
</div>
</div>
);
}

View file

@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
try {
const { searchParams } = request.nextUrl;
const includeUnmapped = searchParams.get('includeUnmapped') === 'true';
const mappings = await postgresClient.query(
`SELECT m.id, m.s1_site_id, m.s1_site_name, m.company_id, m.company_name,
m.notes, m.created_at, m.updated_at,
s.state, s.active_licenses, s.health_status, s.sku
FROM s1_company_mappings m
LEFT JOIN s1_sites s ON s.id = m.s1_site_id
ORDER BY m.s1_site_name`
);
if (!includeUnmapped) {
return NextResponse.json({ mappings: mappings.rows });
}
const allSites = await postgresClient.query(
`SELECT id, name, state, active_licenses, health_status, sku FROM s1_sites ORDER BY name`
);
const mappedIds = new Set(mappings.rows.map((r: any) => r.s1_site_id));
const unmapped = allSites.rows
.filter((s: any) => !mappedIds.has(s.id))
.map((s: any) => ({
id: null,
s1_site_id: s.id,
s1_site_name: s.name,
company_id: null,
company_name: null,
notes: null,
state: s.state,
active_licenses: s.active_licenses,
health_status: s.health_status,
sku: s.sku,
}));
return NextResponse.json({
mappings: [...mappings.rows, ...unmapped],
stats: {
total: allSites.rows.length,
mapped: mappings.rows.length,
unmapped: unmapped.length,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { s1SiteId, s1SiteName, companyId, companyName, notes = null } = body;
if (!s1SiteId || !companyId) {
return NextResponse.json(
{ error: 'Missing required fields: s1SiteId and companyId' },
{ status: 400 }
);
}
const result = await postgresClient.query(
`INSERT INTO s1_company_mappings (s1_site_id, s1_site_name, company_id, company_name, notes)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (s1_site_id) DO UPDATE SET
s1_site_name = $2, company_id = $3, company_name = $4,
notes = $5, updated_at = NOW()
RETURNING *`,
[s1SiteId, s1SiteName, companyId, companyName || null, notes]
);
return NextResponse.json({ success: true, mapping: result.rows[0] });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(request: NextRequest) {
try {
const id = request.nextUrl.searchParams.get('id');
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
const result = await postgresClient.query(
'DELETE FROM s1_company_mappings WHERE id = $1 RETURNING *',
[id]
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Mapping not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,76 @@
import { NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET() {
try {
const result = await postgresClient.query(`
SELECT
s.id AS s1_site_id,
s.name AS s1_site_name,
s.state,
s.sku,
s.health_status,
s.active_licenses,
m.company_id,
m.company_name,
COALESCE(a.total_agents, 0) AS total_agents,
COALESCE(a.active_agents, 0) AS active_agents,
COALESCE(a.infected_agents, 0) AS infected_agents,
COALESCE(a.outdated_agents, 0) AS outdated_agents,
COALESCE(a.decommissioned_agents, 0) AS decommissioned_agents,
a.last_seen,
COALESCE(t.total_threats, 0) AS total_threats,
COALESCE(t.active_threats, 0) AS active_threats
FROM s1_sites s
LEFT JOIN s1_company_mappings m ON m.s1_site_id = s.id
LEFT JOIN (
SELECT
site_id,
COUNT(*) AS total_agents,
COUNT(*) FILTER (WHERE is_active = true) AS active_agents,
COUNT(*) FILTER (WHERE infected = true) AS infected_agents,
COUNT(*) FILTER (WHERE is_up_to_date = false) AS outdated_agents,
COUNT(*) FILTER (WHERE is_decommissioned = true) AS decommissioned_agents,
MAX(updated_at) AS last_seen
FROM s1_agents
WHERE is_decommissioned = false
GROUP BY site_id
) a ON a.site_id = s.id
LEFT JOIN (
SELECT
site_id,
COUNT(*) AS total_threats,
COUNT(*) FILTER (WHERE mitigation_status NOT IN
('mitigated','marked_as_benign','marked_as_false_positive')) AS active_threats
FROM s1_threats
GROUP BY site_id
) t ON t.site_id = s.id
ORDER BY s.name
`);
const rows = result.rows.map((r: any) => ({
...r,
total_agents: Number(r.total_agents),
active_agents: Number(r.active_agents),
infected_agents: Number(r.infected_agents),
outdated_agents: Number(r.outdated_agents),
decommissioned_agents: Number(r.decommissioned_agents),
total_threats: Number(r.total_threats),
active_threats: Number(r.active_threats),
}));
const summary = {
totalSites: rows.length,
mappedSites: rows.filter((r: any) => r.company_id).length,
totalAgents: rows.reduce((s: number, r: any) => s + r.total_agents, 0),
infectedAgents: rows.reduce((s: number, r: any) => s + r.infected_agents, 0),
activeThreats: rows.reduce((s: number, r: any) => s + r.active_threats, 0),
outdatedAgents: rows.reduce((s: number, r: any) => s + r.outdated_agents, 0),
};
return NextResponse.json({ sites: rows, summary });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSentinelOneSyncService } from '@/lib/services/sentinelone-sync-service';
import { postgresClient } from '@/lib/services/postgres-client';
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const triggeredBy = body.triggeredBy || 'manual';
const svc = getSentinelOneSyncService();
if (svc.isSyncInProgress()) {
return NextResponse.json({ error: 'Sync already in progress' }, { status: 409 });
}
svc.fullSync(triggeredBy).catch(err => console.error('[S1Sync] Background sync error:', err));
return NextResponse.json({ success: true, message: 'SentinelOne sync started' });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET() {
try {
const [historyRes, countsRes] = await Promise.all([
postgresClient.query(
`SELECT id, sync_type, status, triggered_by, started_at, completed_at,
duration_ms, total_upserted, error_message, entity_results
FROM s1_sync_history ORDER BY started_at DESC LIMIT 10`
),
postgresClient.query(
`SELECT
(SELECT COUNT(*) FROM s1_sites) AS sites,
(SELECT COUNT(*) FROM s1_agents) AS agents,
(SELECT COUNT(*) FROM s1_agents WHERE infected = true) AS infected,
(SELECT COUNT(*) FROM s1_agents WHERE is_active = true) AS active_agents,
(SELECT COUNT(*) FROM s1_threats) AS threats`
),
]);
const svc = getSentinelOneSyncService();
return NextResponse.json({
inProgress: svc.isSyncInProgress(),
history: historyRes.rows,
counts: countsRes.rows[0],
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,187 @@
'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 {
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 (
<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 Coverage
</h1>
<p className="text-muted-foreground mt-1">AV agent coverage and threat status per site</p>
</div>
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />Refresh
</Button>
</div>
{/* 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>
);
}

View file

@ -0,0 +1,290 @@
'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>
);
}