wulf-pulse/app/admin/sync/itglue/page.tsx

348 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
ArrowLeft, Activity, History, BookOpen, Loader2, RefreshCw,
ExternalLink, CheckCircle2, XCircle, Clock, AlertTriangle,
Building2, Monitor, Users, Key, FileText, Globe, Shield, Package,
} from 'lucide-react';
function fmtDate(d: string | null) {
if (!d) return 'Never';
return new Date(d).toLocaleString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function fmtDuration(ms: number | null) {
if (!ms) return '—';
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
}
function StatCard({
label, value, sub, icon: Icon, cls,
}: {
label: string; value: string | number; sub?: string;
icon?: React.ElementType; cls?: string;
}) {
return (
<div className={`rounded-lg border p-4 flex flex-col gap-1 ${cls ?? ''}`}>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon && <Icon className="w-3.5 h-3.5" />}{label}
</div>
<div className="text-2xl font-bold tabular-nums">{value}</div>
{sub && <div className="text-xs text-muted-foreground">{sub}</div>}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const cls =
status === 'completed' ? 'bg-green-500/15 text-green-700' :
status === 'failed' ? 'bg-red-500/15 text-red-600' :
status === 'running' ? 'bg-blue-500/15 text-blue-700' :
'bg-yellow-500/15 text-yellow-700';
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${cls}`}>
{status === 'running' && <Loader2 className="w-3 h-3 animate-spin" />}
{status}
</span>
);
}
function StatusTab({
syncData, onSync, syncing,
}: {
syncData: any; onSync: () => void; syncing: boolean;
}) {
if (!syncData) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
);
}
const counts = syncData.counts ?? {};
const latest = syncData.history?.[0];
return (
<div className="space-y-6">
{/* Connection bar */}
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
<div className="space-y-0.5">
<p className="text-sm font-medium flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500" />
Connected to IT Glue
</p>
<p className="text-xs text-muted-foreground">
Last sync: {fmtDate(latest?.completed_at ?? null)}
{latest?.duration_ms && ` · ${fmtDuration(latest.duration_ms)}`}
</p>
</div>
<div className="flex gap-2">
<a href="https://app.itglue.com" target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm" className="gap-2">
<ExternalLink className="w-4 h-4" />Portal
</Button>
</a>
<Button size="sm" onClick={onSync} disabled={syncing || syncData.inProgress}>
{syncing || syncData.inProgress
? <Loader2 className="w-4 h-4 animate-spin mr-2" />
: <RefreshCw className="w-4 h-4 mr-2" />}
{syncing || syncData.inProgress ? 'Syncing…' : 'Full Sync'}
</Button>
</div>
</div>
{/* Record counts */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">Synced Records</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Organizations" value={Number(counts.organizations ?? 0).toLocaleString()} icon={Building2} />
<StatCard label="Configurations" value={Number(counts.configurations ?? 0).toLocaleString()} icon={Monitor} />
<StatCard label="Contacts" value={Number(counts.contacts ?? 0).toLocaleString()} icon={Users} />
<StatCard label="Flexible Assets" value={Number(counts.flexible_assets ?? 0).toLocaleString()} icon={Package} />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3">
<StatCard label="Passwords" value={Number(counts.passwords ?? 0).toLocaleString()} icon={Key} />
<StatCard label="Documents" value={Number(counts.documents ?? 0).toLocaleString()} icon={FileText} />
<StatCard label="Locations" value={Number(counts.locations ?? 0).toLocaleString()} icon={Building2} />
<StatCard label="Domains" value={Number(counts.domains ?? 0).toLocaleString()} icon={Globe} />
</div>
</div>
{/* Latest sync entity breakdown */}
{latest?.entities?.length > 0 && (
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-3">
Last Sync Breakdown
{latest.status && <span className="ml-2"><StatusBadge status={latest.status} /></span>}
</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Entity</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{latest.entities.map((e: any, i: number) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2 font-mono text-xs">{e.entity}</td>
<td className="px-4 py-2 text-right tabular-nums">{e.recordsUpserted.toLocaleString()}</td>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(e.duration)}</td>
<td className="px-4 py-2 text-right">
{e.success
? <CheckCircle2 className="w-4 h-4 text-green-500 inline" />
: <span title={e.error}><XCircle className="w-4 h-4 text-red-500 inline" /></span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
function HistoryTab({ history }: { history: any[] }) {
if (!history.length) {
return (
<div className="text-center py-12 text-muted-foreground text-sm">
No sync history yet run a sync to populate
</div>
);
}
return (
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b">
<tr>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Status</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Triggered By</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Records</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Started</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{history.map((row: any, i: number) => (
<tr key={i} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-2"><StatusBadge status={row.status} /></td>
<td className="px-4 py-2 text-muted-foreground capitalize">{row.triggered_by ?? 'system'}</td>
<td className="px-4 py-2 text-right tabular-nums">{(row.total_upserted ?? 0).toLocaleString()}</td>
<td className="px-4 py-2 text-muted-foreground">{fmtDate(row.started_at)}</td>
<td className="px-4 py-2 text-right text-muted-foreground">{fmtDuration(row.duration_ms)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function AboutTab() {
return (
<div className="space-y-4 text-sm text-muted-foreground">
<div className="rounded-lg border p-4 space-y-3">
<p className="font-medium text-foreground">Synced Entities</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{[
['Organizations', 'All IT Glue organizations with type, status, PSA linkage'],
['Locations', 'Physical locations per organization with address details'],
['Contacts', 'Contacts with emails, phones, type, and location linkage'],
['Configurations', 'All CIs with hostname, IP, serial, OS, manufacturer, model'],
['Flexible Assets', 'All flexible asset types with full trait data as JSONB'],
['Flexible Asset Types', 'Type definitions and field schemas'],
['Passwords', 'Credentials with category, folder, username, URL'],
['Password Folders', 'Folder hierarchy per organization'],
['Documents', 'IT Glue documents with full content'],
['Domains', 'Domain records with expiry and registrar info'],
['Expirations', 'All expiration records across organizations'],
['Reference Tables', 'Org types/statuses, config types/statuses, contact types, manufacturers, models, OS, platforms, countries'],
].map(([name, desc]) => (
<div key={name} className="flex gap-2">
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
<div>
<span className="font-medium text-foreground">{name}</span>
<span className="text-xs block">{desc}</span>
</div>
</div>
))}
</div>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Authentication</p>
<p>API key via <code className="text-xs bg-muted px-1 py-0.5 rounded">x-api-key</code> header · Base URL: <code className="text-xs bg-muted px-1 py-0.5 rounded">https://api.itglue.com</code></p>
<p>Response format: JSON:API (<code className="text-xs bg-muted px-1 py-0.5 rounded">application/vnd.api+json</code>)</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground">Database Tables</p>
<p>All data is stored in tables prefixed <code className="text-xs bg-muted px-1 py-0.5 rounded">itg_</code> in the Pulse PostgreSQL database. Each table includes a <code className="text-xs bg-muted px-1 py-0.5 rounded">synced_at</code> timestamp and uses <code className="text-xs bg-muted px-1 py-0.5 rounded">ON CONFLICT DO UPDATE</code> for idempotent upserts.</p>
</div>
<div className="rounded-lg border p-4 space-y-2">
<p className="font-medium text-foreground flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-500" />
Notes
</p>
<ul className="space-y-1 list-disc list-inside text-xs">
<li>A full sync takes ~710 minutes depending on data volume</li>
<li>Configuration interfaces are not synced the IT Glue API has no flat endpoint and per-config calls are impractical at 14k+ configs</li>
<li>Flexible assets require a per-type API call (API enforces <code className="bg-muted px-1 py-0.5 rounded">filter[flexible-asset-type-id]</code>)</li>
<li>Password folders, documents, and expirations require per-organization calls</li>
</ul>
</div>
</div>
);
}
export default function ITGluePage() {
const [syncData, setSyncData] = useState<any>(null);
const [syncing, setSyncing] = useState(false);
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/itglue/sync');
if (res.ok) setSyncData(await res.json());
} catch {}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Poll while sync is in progress
useEffect(() => {
if (!syncData?.inProgress && !syncing) return;
const interval = setInterval(fetchStatus, 5000);
return () => clearInterval(interval);
}, [syncData?.inProgress, syncing, fetchStatus]);
const handleSync = async () => {
setSyncing(true);
try {
await fetch('/api/itglue/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ triggeredBy: 'manual' }),
});
await fetchStatus();
} catch {
setSyncing(false);
}
// syncing flag cleared by poll detecting inProgress=false
};
// Clear syncing flag once inProgress goes false
useEffect(() => {
if (syncData && !syncData.inProgress && syncing) {
setSyncing(false);
}
}, [syncData, syncing]);
return (
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/admin/sync">
<Button variant="outline" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" />
Integrations
</Button>
</Link>
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg border border-blue-500/30 bg-blue-500/5">
<Shield className="w-5 h-5 text-blue-500" />
</div>
<div>
<h1 className="text-2xl font-bold">IT Glue</h1>
<p className="text-sm text-muted-foreground">
Organizations, configurations, contacts, passwords, flexible assets
</p>
</div>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-3">
<TabsTrigger value="status" className="gap-2">
<Activity className="h-4 w-4" />Status
</TabsTrigger>
<TabsTrigger value="history" className="gap-2">
<History className="h-4 w-4" />History
</TabsTrigger>
<TabsTrigger value="about" className="gap-2">
<BookOpen className="h-4 w-4" />About
</TabsTrigger>
</TabsList>
<TabsContent value="status" className="mt-6">
<StatusTab syncData={syncData} onSync={handleSync} syncing={syncing} />
</TabsContent>
<TabsContent value="history" className="mt-6">
<HistoryTab history={syncData?.history ?? []} />
</TabsContent>
<TabsContent value="about" className="mt-6">
<AboutTab />
</TabsContent>
</Tabs>
</div>
);
}