Restructure: rename to Pulse and move app to root
- Renamed project from PSA-Utils to Pulse - Moved all app files from autotask-app/ to root - Updated package.json name to 'pulse' - Updated Docker container names to pulse-app and pulse-redis - Updated Docker network name to pulse-network
This commit is contained in:
parent
f429f3af54
commit
3c3124d8c9
117 changed files with 8433 additions and 239 deletions
195
app/configuration-items/[id]/page.tsx
Normal file
195
app/configuration-items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { PSATab } from '@/components/configuration-items/psa-tab';
|
||||
import { RMMTab } from '@/components/configuration-items/rmm-tab';
|
||||
import { StatusCards } from '@/components/configuration-items/status-cards';
|
||||
import {
|
||||
Server,
|
||||
Monitor,
|
||||
ArrowLeft,
|
||||
AlertCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { ConfigurationItem } from '@/lib/types/autotask';
|
||||
import { DattoRMMDevice } from '@/lib/types/datto-rmm';
|
||||
|
||||
interface ConfigItemDetail {
|
||||
autotaskDevice?: ConfigurationItem;
|
||||
rmmDevice?: DattoRMMDevice;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
export default function ConfigurationItemDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [data, setData] = useState<ConfigItemDetail>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get company context from URL params
|
||||
const companyId = searchParams.get('companyId');
|
||||
const companyName = searchParams.get('companyName');
|
||||
|
||||
// Parse the ID parameter - it might be "at-123" or "rmm-abc" or "123"
|
||||
const parseItemId = () => {
|
||||
const id = params.id as string;
|
||||
if (id.startsWith('at-')) {
|
||||
return { type: 'autotask', id: id.substring(3) };
|
||||
} else if (id.startsWith('rmm-')) {
|
||||
return { type: 'rmm', id: id.substring(4) };
|
||||
} else {
|
||||
return { type: 'autotask', id }; // Default to Autotask
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { type, id } = parseItemId();
|
||||
|
||||
// Fetch the configuration item details
|
||||
const response = await fetch(`/api/configuration-items/${id}?type=${type}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch configuration item');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [params.id]);
|
||||
|
||||
const handleUpdate = (updatedDevice: ConfigurationItem) => {
|
||||
setData({ ...data, autotaskDevice: updatedDevice });
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Card className="border-red-500">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>Error: {error}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const device = data.autotaskDevice;
|
||||
const rmmDevice = data.rmmDevice;
|
||||
|
||||
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"
|
||||
onClick={() => {
|
||||
// Navigate back to configuration items with company context
|
||||
if (companyId) {
|
||||
const params = new URLSearchParams({
|
||||
companyId: companyId,
|
||||
companyName: companyName || ''
|
||||
});
|
||||
router.push(`/configuration-items?${params.toString()}`);
|
||||
} else {
|
||||
router.push('/configuration-items');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to List
|
||||
</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">
|
||||
{device?.referenceTitle || rmmDevice?.hostname || 'Configuration Item'}
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{data.companyName || 'Configuration Item Details'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => window.location.reload()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Status Cards */}
|
||||
<StatusCards device={device} rmmDevice={rmmDevice} />
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="psa" className="space-y-4 mt-6">
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-md">
|
||||
<TabsTrigger value="psa" className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" />
|
||||
PSA Data
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rmm" className="flex items-center gap-2">
|
||||
<Monitor className="w-4 h-4" />
|
||||
RMM Data
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="psa">
|
||||
<PSATab device={device} onUpdate={handleUpdate} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rmm">
|
||||
<RMMTab device={rmmDevice} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
766
app/configuration-items/page.tsx
Normal file
766
app/configuration-items/page.tsx
Normal file
|
|
@ -0,0 +1,766 @@
|
|||
'use client';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
const handleCompanyChange = (companyId: number | undefined, companyName?: string) => {
|
||||
setSelectedCompany(companyId);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConfigurationItemsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-8">Loading...</div>}>
|
||||
<ConfigurationItemsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue