wulf-pulse/app/configuration-items/page.tsx

767 lines
31 KiB
TypeScript
Raw Normal View History

'use client';
Add Addigy API integration and Docker deployment with Redis caching - Implemented complete Addigy API v2 client with authentication via x-api-key - Added device and policy endpoints with automatic org ID resolution - Created field mapping from snake_case to Title Case for UI compatibility - Handles nested 'facts' response structure from Addigy devices API - Added comprehensive API documentation in ADDIGY_API_GUIDE.md - Multi-stage Dockerfile with optimized production build - Custom ports: App on 3100, Redis on 6380 (avoids conflicts) - Docker Compose orchestration with health checks - Standalone Next.js output for smaller container images - Non-root user execution for security - Implemented Redis caching layer for API responses - 5-minute TTL with graceful fallback if Redis unavailable - Cache key structure: service:entity:filter1:filter2 - Applied to Addigy devices endpoint with cache hit/miss logging - Fixed TypeScript strict mode errors for production builds - Added null safety checks with optional chaining throughout API routes - Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility - Fixed type assertions for dynamic API responses - Corrected Set<string> type mismatches in device comparison logic - Created DOCKER_README.md with complete deployment guide - Updated ADDIGY_API_GUIDE.md with real-world API patterns - Documented response structures, field mappings, and troubleshooting - Next.js 16.0.0 with Turbopack - Redis 7 with AOF persistence - Podman/Docker compatible - TypeScript strict mode compliant
2025-10-28 22:49:08 -04:00
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CompanySelectorEnhanced } from '@/components/companies/company-selector-enhanced';
import { ThemeToggle } from '@/components/theme-toggle';
import { ConfigItemModal } from '@/components/configuration-items/config-item-modal';
import { ContactCell } from '@/components/configuration-items/contact-cell';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Checkbox } from '@/components/ui/checkbox';
import { Calendar as CalendarComponent } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Server,
Monitor,
HardDrive,
Network,
AlertCircle,
CheckCircle,
XCircle,
RefreshCw,
Search,
Settings,
Activity,
Cpu,
MemoryStick,
Wifi,
Shield,
Calendar as CalendarIcon,
Hash,
Building2,
ArrowLeft,
Filter,
Download,
ChevronRight,
Info,
Power
} from 'lucide-react';
import { format } from 'date-fns';
import { ConfigurationItem } from '@/lib/types/autotask';
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
import { useApi } from '@/lib/hooks/use-api';
interface DeviceComparison {
autotaskDevice?: ConfigurationItem;
rmmDevice?: DattoRMMDevice;
status: 'matched' | 'autotask-only' | 'rmm-only';
matchedBy?: string;
}
Add Addigy API integration and Docker deployment with Redis caching - Implemented complete Addigy API v2 client with authentication via x-api-key - Added device and policy endpoints with automatic org ID resolution - Created field mapping from snake_case to Title Case for UI compatibility - Handles nested 'facts' response structure from Addigy devices API - Added comprehensive API documentation in ADDIGY_API_GUIDE.md - Multi-stage Dockerfile with optimized production build - Custom ports: App on 3100, Redis on 6380 (avoids conflicts) - Docker Compose orchestration with health checks - Standalone Next.js output for smaller container images - Non-root user execution for security - Implemented Redis caching layer for API responses - 5-minute TTL with graceful fallback if Redis unavailable - Cache key structure: service:entity:filter1:filter2 - Applied to Addigy devices endpoint with cache hit/miss logging - Fixed TypeScript strict mode errors for production builds - Added null safety checks with optional chaining throughout API routes - Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility - Fixed type assertions for dynamic API responses - Corrected Set<string> type mismatches in device comparison logic - Created DOCKER_README.md with complete deployment guide - Updated ADDIGY_API_GUIDE.md with real-world API patterns - Documented response structures, field mappings, and troubleshooting - Next.js 16.0.0 with Turbopack - Redis 7 with AOF persistence - Podman/Docker compatible - TypeScript strict mode compliant
2025-10-28 22:49:08 -04:00
function ConfigurationItemsContent() {
const searchParams = useSearchParams();
const [selectedCompany, setSelectedCompany] = useState<number | undefined>();
const [selectedCompanyName, setSelectedCompanyName] = useState<string>('');
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [configItems, setConfigItems] = useState<ConfigurationItem[]>([]);
const [comparison, setComparison] = useState<DeviceComparison[]>([]);
const [stats, setStats] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'autotask' | 'comparison'>('comparison');
const [selectedItemId, setSelectedItemId] = useState<string | number | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [adminExpanded, setAdminExpanded] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
const [bulkProcessing, setBulkProcessing] = useState(false);
const [lastSeenAfterDate, setLastSeenAfterDate] = useState<Date | undefined>();
const [activeFilter, setActiveFilter] = useState<'active' | 'inactive' | 'all'>('active');
const [displayLimit, setDisplayLimit] = useState(50); // Start with 50 items
// Initialize company from URL params on mount
useEffect(() => {
const companyIdParam = searchParams.get('companyId');
const companyNameParam = searchParams.get('companyName');
if (companyIdParam) {
setSelectedCompany(parseInt(companyIdParam));
setSelectedCompanyName(companyNameParam || '');
}
}, [searchParams]);
// Fetch configuration items when company changes
useEffect(() => {
if (!selectedCompany) {
setConfigItems([]);
return;
}
const fetchConfigItems = async () => {
setLoading(true);
setError(null);
try {
// Fetch comparison data (includes both Autotask and RMM)
const response = await fetch(
`/api/rmm-devices?companyId=${selectedCompany}&companyName=${encodeURIComponent(selectedCompanyName)}&activeFilter=${activeFilter}`
);
if (!response.ok) {
throw new Error('Failed to fetch devices');
}
const data = await response.json();
setComparison(data.comparison || []);
setStats(data.stats);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setComparison([]);
} finally {
setLoading(false);
}
};
fetchConfigItems();
}, [selectedCompany, selectedCompanyName, activeFilter]);
// Filter comparison items based on search and type
const filteredComparison = comparison.filter((item: DeviceComparison) => {
const deviceName = item.autotaskDevice?.referenceTitle || item.rmmDevice?.hostname || '';
const serialNumber = item.autotaskDevice?.serialNumber || item.rmmDevice?.serialNumber || '';
const searchLower = searchTerm.toLowerCase();
const matchesSearch = deviceName.toLowerCase().includes(searchLower) ||
serialNumber.toLowerCase().includes(searchLower);
const matchesType = filterType === 'all' ||
(filterType === 'matched' && item.status === 'matched') ||
(filterType === 'autotask-only' && item.status === 'autotask-only') ||
(filterType === 'rmm-only' && item.status === 'rmm-only');
// Filter by last seen date in RMM (after specified date)
let matchesLastSeen = true;
if (lastSeenAfterDate && item.rmmDevice?.lastSeen) {
const lastSeenDate = new Date(item.rmmDevice.lastSeen);
matchesLastSeen = lastSeenDate >= lastSeenAfterDate;
}
return matchesSearch && matchesType && matchesLastSeen;
});
// Handle company selection
Add Addigy API integration and Docker deployment with Redis caching - Implemented complete Addigy API v2 client with authentication via x-api-key - Added device and policy endpoints with automatic org ID resolution - Created field mapping from snake_case to Title Case for UI compatibility - Handles nested 'facts' response structure from Addigy devices API - Added comprehensive API documentation in ADDIGY_API_GUIDE.md - Multi-stage Dockerfile with optimized production build - Custom ports: App on 3100, Redis on 6380 (avoids conflicts) - Docker Compose orchestration with health checks - Standalone Next.js output for smaller container images - Non-root user execution for security - Implemented Redis caching layer for API responses - 5-minute TTL with graceful fallback if Redis unavailable - Cache key structure: service:entity:filter1:filter2 - Applied to Addigy devices endpoint with cache hit/miss logging - Fixed TypeScript strict mode errors for production builds - Added null safety checks with optional chaining throughout API routes - Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility - Fixed type assertions for dynamic API responses - Corrected Set<string> type mismatches in device comparison logic - Created DOCKER_README.md with complete deployment guide - Updated ADDIGY_API_GUIDE.md with real-world API patterns - Documented response structures, field mappings, and troubleshooting - Next.js 16.0.0 with Turbopack - Redis 7 with AOF persistence - Podman/Docker compatible - TypeScript strict mode compliant
2025-10-28 22:49:08 -04:00
const handleCompanyChange = (companyId: number | undefined, companyName?: string) => {
setSelectedCompany(companyId);
Add Addigy API integration and Docker deployment with Redis caching - Implemented complete Addigy API v2 client with authentication via x-api-key - Added device and policy endpoints with automatic org ID resolution - Created field mapping from snake_case to Title Case for UI compatibility - Handles nested 'facts' response structure from Addigy devices API - Added comprehensive API documentation in ADDIGY_API_GUIDE.md - Multi-stage Dockerfile with optimized production build - Custom ports: App on 3100, Redis on 6380 (avoids conflicts) - Docker Compose orchestration with health checks - Standalone Next.js output for smaller container images - Non-root user execution for security - Implemented Redis caching layer for API responses - 5-minute TTL with graceful fallback if Redis unavailable - Cache key structure: service:entity:filter1:filter2 - Applied to Addigy devices endpoint with cache hit/miss logging - Fixed TypeScript strict mode errors for production builds - Added null safety checks with optional chaining throughout API routes - Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility - Fixed type assertions for dynamic API responses - Corrected Set<string> type mismatches in device comparison logic - Created DOCKER_README.md with complete deployment guide - Updated ADDIGY_API_GUIDE.md with real-world API patterns - Documented response structures, field mappings, and troubleshooting - Next.js 16.0.0 with Turbopack - Redis 7 with AOF persistence - Podman/Docker compatible - TypeScript strict mode compliant
2025-10-28 22:49:08 -04:00
setSelectedCompanyName(companyName || '');
setSelectedItems(new Set()); // Clear selections when company changes
};
const handleSelectItem = (itemId: number, checked: boolean) => {
const newSelected = new Set(selectedItems);
if (checked) {
newSelected.add(itemId);
} else {
newSelected.delete(itemId);
}
setSelectedItems(newSelected);
};
const handleSelectAll = (checked: boolean) => {
if (checked) {
const allIds = new Set(
filteredComparison
.filter(item => item.autotaskDevice?.id)
.map(item => item.autotaskDevice!.id)
);
setSelectedItems(allIds);
} else {
setSelectedItems(new Set());
}
};
const handleBulkMakeInactive = async () => {
if (selectedItems.size === 0) return;
setBulkProcessing(true);
try {
const promises = Array.from(selectedItems).map(itemId =>
fetch(`/api/configuration-items/${itemId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: false }),
})
);
await Promise.all(promises);
// Refresh the data
if (selectedCompany) {
const response = await fetch(
`/api/rmm-devices?companyId=${selectedCompany}&companyName=${encodeURIComponent(selectedCompanyName)}`
);
if (response.ok) {
const data = await response.json();
setComparison(data.comparison || []);
setStats(data.stats);
}
}
setSelectedItems(new Set());
setAdminExpanded(false);
} catch (err) {
console.error('Bulk operation failed:', err);
setError('Failed to make items inactive');
} finally {
setBulkProcessing(false);
}
};
const getDeviceIcon = (item: ConfigurationItem) => {
if (item.rmmDeviceAuditDeviceTypeID) {
// You can map device type IDs to specific icons
return <Monitor className="w-4 h-4" />;
}
if (item.dattoSerialNumber) {
return <HardDrive className="w-4 h-4" />;
}
return <Server className="w-4 h-4" />;
};
const getRMMStatus = (item: ConfigurationItem) => {
if (item.rmmDeviceUID) {
return (
<Badge variant="default" className="bg-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
RMM Connected
</Badge>
);
}
return (
<Badge variant="secondary">
<XCircle className="w-3 h-3 mr-1" />
No RMM
</Badge>
);
};
const getDattoStatus = (item: ConfigurationItem) => {
if (item.dattoSerialNumber) {
return (
<Badge variant="default" className="bg-blue-600">
<Shield className="w-3 h-3 mr-1" />
Datto Protected
</Badge>
);
}
return null;
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center">
<div className="flex flex-1 items-center justify-between">
<div className="flex items-center space-x-4">
<Button variant="ghost" size="sm" asChild>
<a href="/">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</a>
</Button>
<div className="flex items-center space-x-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-purple-600 to-purple-700 text-white shadow-lg">
<Server className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-semibold tracking-tight">
Configuration Items
</h1>
<p className="text-xs text-muted-foreground">Autotask & RMM Device Management</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={() => selectedCompany && setSelectedCompany(selectedCompany)}>
<RefreshCw className="h-4 w-4" />
</Button>
<ThemeToggle />
<Button size="sm" className="bg-gradient-to-r from-purple-600 to-purple-700 text-white hover:from-purple-700 hover:to-purple-800">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="container mx-auto px-4 py-8">
{/* Company Selector Card */}
<Card className="mb-6 border-0 shadow-lg">
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Select Company</CardTitle>
<CardDescription>
Choose a company to view their configuration items
</CardDescription>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white dark:bg-gray-900 shadow-sm">
<Building2 className="h-4 w-4 text-muted-foreground" />
</div>
</div>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2">
<CompanySelectorEnhanced
value={selectedCompany}
onValueChange={handleCompanyChange}
label="Select Company"
/>
</div>
{selectedCompany && (
<div className="flex items-end">
<Card className="w-full border-0 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Total Devices</p>
<p className="text-2xl font-bold">
PSA: {stats?.totalAutotask || 0} | RMM: {stats?.totalRmm || 0}
</p>
</div>
<Server className="h-8 w-8 text-purple-600" />
</div>
</CardContent>
</Card>
</div>
)}
</div>
</CardContent>
</Card>
{/* Admin Section */}
{selectedCompany && filteredComparison.length > 0 && (
<Card className="border-0 shadow-lg border-l-4 border-l-orange-500">
<Collapsible open={adminExpanded} onOpenChange={setAdminExpanded}>
<CardHeader className="pb-3">
<CollapsibleTrigger className="flex items-center justify-between w-full hover:opacity-70 transition-opacity">
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="w-5 h-5 text-orange-600" />
Admin Actions
{selectedItems.size > 0 && (
<Badge variant="secondary" className="ml-2">
{selectedItems.size} selected
</Badge>
)}
</CardTitle>
<ChevronRight className={`w-5 h-5 transition-transform ${adminExpanded ? 'rotate-90' : ''}`} />
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent>
<div className="flex items-center justify-between p-4 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-200 dark:border-orange-800">
<div>
<p className="font-medium">Bulk Actions</p>
<p className="text-sm text-muted-foreground">
{selectedItems.size} device{selectedItems.size !== 1 ? 's' : ''} selected
</p>
</div>
<div className="flex gap-2">
<Button
variant="destructive"
onClick={handleBulkMakeInactive}
disabled={selectedItems.size === 0 || bulkProcessing}
>
{bulkProcessing ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
<>
<Power className="w-4 h-4 mr-2" />
Make Inactive ({selectedItems.size})
</>
)}
</Button>
</div>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)}
{/* Filters and Search */}
{selectedCompany && (
<Card className="mb-6 border-0 shadow-lg">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="w-5 h-5" />
Filters & Search
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="search">Search Devices</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Search by name, serial, IP..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="space-y-2">
<Label>PSA Status</Label>
<Select value={activeFilter} onValueChange={(value: 'active' | 'inactive' | 'all') => setActiveFilter(value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">Active Only</SelectItem>
<SelectItem value="inactive">Inactive Only</SelectItem>
<SelectItem value="all">All (Active & Inactive)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Device Type</Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Devices</SelectItem>
<SelectItem value="matched">Matched (In Both)</SelectItem>
<SelectItem value="autotask-only">Autotask Only</SelectItem>
<SelectItem value="rmm-only">RMM Only</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Last Seen After</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`w-full justify-start text-left font-normal ${!lastSeenAfterDate && "text-muted-foreground"}`}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{lastSeenAfterDate ? format(lastSeenAfterDate, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
mode="single"
selected={lastSeenAfterDate}
onSelect={setLastSeenAfterDate}
initialFocus
/>
</PopoverContent>
</Popover>
{lastSeenAfterDate && (
<Button
variant="ghost"
size="sm"
onClick={() => setLastSeenAfterDate(undefined)}
className="w-full"
>
Clear Filter
</Button>
)}
</div>
<div className="flex items-end gap-2">
<Card className="flex-1 border-0 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">Matched</p>
<p className="text-lg font-bold">
{stats?.matched || 0}
</p>
</div>
<CheckCircle className="h-5 w-5 text-green-600" />
</div>
</CardContent>
</Card>
<Card className="flex-1 border-0 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">RMM Only</p>
<p className="text-lg font-bold">
{stats?.rmmOnly || 0}
</p>
</div>
<Monitor className="h-5 w-5 text-blue-600" />
</div>
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
)}
{/* Configuration Items Table */}
{selectedCompany && (
<Card className="border-0 shadow-lg">
<CardHeader className="bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 rounded-t-lg">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Server className="w-5 h-5 text-purple-600" />
Device Comparison
<Badge variant="secondary" className="ml-2">{filteredComparison.length}</Badge>
</CardTitle>
{loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw className="w-4 h-4 animate-spin" />
Loading...
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-6">
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : error ? (
<div className="text-red-500 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
Error: {error}
</div>
) : filteredComparison.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
{!selectedCompany ? (
<div>
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>Select a company to view configuration items</p>
</div>
) : searchTerm || filterType !== 'all' ? (
<div>
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No devices found matching your filters</p>
</div>
) : (
<div>
<Server className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No configuration items found for this company</p>
</div>
)}
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedItems.size === filteredComparison.filter(i => i.autotaskDevice).length && selectedItems.size > 0}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead>Status</TableHead>
<TableHead>Device Name</TableHead>
<TableHead>Serial Number</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Contact</TableHead>
<TableHead>PSA</TableHead>
<TableHead>RMM</TableHead>
<TableHead>Match Type</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredComparison.slice(0, displayLimit).map((item: DeviceComparison, index: number) => (
<TableRow key={`comparison-${index}`}>
<TableCell>
{item.autotaskDevice?.id && (
<Checkbox
checked={selectedItems.has(item.autotaskDevice.id)}
onCheckedChange={(checked) => handleSelectItem(item.autotaskDevice!.id, checked as boolean)}
/>
)}
</TableCell>
<TableCell>
{item.status === 'matched' && (
<Badge variant="default" className="bg-green-600">
<CheckCircle className="w-3 h-3 mr-1" />
Matched
</Badge>
)}
{item.status === 'autotask-only' && (
<Badge variant="secondary">
<Server className="w-3 h-3 mr-1" />
AT Only
</Badge>
)}
{item.status === 'rmm-only' && (
<Badge variant="outline">
<Monitor className="w-3 h-3 mr-1" />
RMM Only
</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
<div>
<div className="font-medium">
{item.autotaskDevice?.referenceTitle ||
item.rmmDevice?.hostname ||
'Unknown Device'}
</div>
{(item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description) && (
<div className="text-xs text-muted-foreground">
{item.autotaskDevice?.rmmDeviceAuditHostname || item.rmmDevice?.description}
</div>
)}
</div>
</div>
</TableCell>
<TableCell className="font-mono text-sm">
{item.autotaskDevice?.serialNumber ||
item.rmmDevice?.serialNumber ||
'-'}
</TableCell>
<TableCell className="font-mono text-sm">
{item.autotaskDevice?.rmmDeviceAuditIPAddress ||
item.rmmDevice?.intIpAddress ||
'-'}
</TableCell>
<TableCell>
<ContactCell contactId={item.autotaskDevice?.contactID} />
</TableCell>
<TableCell>
{item.autotaskDevice ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<XCircle className="w-4 h-4 text-gray-400" />
)}
</TableCell>
<TableCell>
{item.rmmDevice ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<XCircle className="w-4 h-4 text-gray-400" />
)}
</TableCell>
<TableCell>
{item.matchedBy && (
<Badge variant="outline" className="text-xs">
{item.matchedBy}
</Badge>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => {
const itemId = item.autotaskDevice?.id || item.rmmDevice?.id;
if (itemId) {
setSelectedItemId(itemId);
setModalOpen(true);
}
}}
>
<ChevronRight className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Load More Button */}
{filteredComparison.length > displayLimit && (
<div className="flex justify-center py-4">
<Button
variant="outline"
onClick={() => setDisplayLimit(prev => prev + 50)}
>
Load More ({filteredComparison.length - displayLimit} remaining)
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
{/* Info Card when no company selected */}
{!selectedCompany && (
<Card className="border-0 shadow-lg">
<CardContent className="py-12">
<div className="text-center">
<div className="flex justify-center mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900">
<Info className="h-8 w-8 text-purple-600" />
</div>
</div>
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
<p className="text-muted-foreground mb-4 max-w-md mx-auto">
Select a company from the dropdown above to view and manage their configuration items.
You can compare devices between Autotask and RMM systems.
</p>
<div className="flex justify-center gap-4 mt-6">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="w-4 h-4 text-green-600" />
View Autotask devices
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="w-4 h-4 text-green-600" />
Check RMM status
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle className="w-4 h-4 text-green-600" />
Monitor Datto devices
</div>
</div>
</div>
</CardContent>
</Card>
)}
</main>
{/* Configuration Item Detail Modal */}
<ConfigItemModal
itemId={selectedItemId}
type="autotask"
open={modalOpen}
onOpenChange={setModalOpen}
/>
</div>
);
}
Add Addigy API integration and Docker deployment with Redis caching - Implemented complete Addigy API v2 client with authentication via x-api-key - Added device and policy endpoints with automatic org ID resolution - Created field mapping from snake_case to Title Case for UI compatibility - Handles nested 'facts' response structure from Addigy devices API - Added comprehensive API documentation in ADDIGY_API_GUIDE.md - Multi-stage Dockerfile with optimized production build - Custom ports: App on 3100, Redis on 6380 (avoids conflicts) - Docker Compose orchestration with health checks - Standalone Next.js output for smaller container images - Non-root user execution for security - Implemented Redis caching layer for API responses - 5-minute TTL with graceful fallback if Redis unavailable - Cache key structure: service:entity:filter1:filter2 - Applied to Addigy devices endpoint with cache hit/miss logging - Fixed TypeScript strict mode errors for production builds - Added null safety checks with optional chaining throughout API routes - Wrapped useSearchParams in Suspense boundary for Next.js 15+ compatibility - Fixed type assertions for dynamic API responses - Corrected Set<string> type mismatches in device comparison logic - Created DOCKER_README.md with complete deployment guide - Updated ADDIGY_API_GUIDE.md with real-world API patterns - Documented response structures, field mappings, and troubleshooting - Next.js 16.0.0 with Turbopack - Redis 7 with AOF persistence - Podman/Docker compatible - TypeScript strict mode compliant
2025-10-28 22:49:08 -04:00
export default function ConfigurationItemsPage() {
return (
<Suspense fallback={<div className="p-8">Loading...</div>}>
<ConfigurationItemsContent />
</Suspense>
);
}