348 lines
15 KiB
TypeScript
348 lines
15 KiB
TypeScript
'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 ~7–10 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>
|
||
);
|
||
}
|