feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder - Add webhook delivery system with Teams incoming webhooks - Add admin UI at /admin/morning-summary for webhook/config management - Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history - Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM) - Add outages_only filter (Unavailable triggers only) - Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts - Fix resolved events: event.get value:1 scoped to window with r_eventid filter - Remove emojis from fact rows and section headers in card - Remove Open Zabbix button (duplicate of View Problems) - Add migrations: morning_summary_config + morning_summaries tables - Add outages_only column to morning_summary_config
This commit is contained in:
parent
19605f82aa
commit
c518eefdb2
61 changed files with 11236 additions and 237 deletions
|
|
@ -1,9 +1,15 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -12,7 +18,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, CheckCircle2, XCircle, Loader2, ExternalLink, Package } from 'lucide-react';
|
||||
|
||||
interface ComplianceMismatch {
|
||||
id: number;
|
||||
|
|
@ -25,15 +31,224 @@ interface ComplianceMismatch {
|
|||
device_name: string;
|
||||
contract_name: string | null;
|
||||
veeam_workload_name: string | null;
|
||||
billing_covered: boolean | null;
|
||||
billing_contract_name: string | null;
|
||||
billing_contracted_qty: number | null;
|
||||
billing_contract_id: number | null;
|
||||
coverage_source: string | null;
|
||||
}
|
||||
|
||||
interface ContractService {
|
||||
id: number;
|
||||
service_id: number | null;
|
||||
display_name: string;
|
||||
description: string | null;
|
||||
unit_price: number | null;
|
||||
unit_cost: number | null;
|
||||
quantity: number | null;
|
||||
adjusted_price: number | null;
|
||||
period_label: string | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
interface ContractDetail {
|
||||
id: number;
|
||||
contract_name: string;
|
||||
company_name: string;
|
||||
status: number;
|
||||
contract_type: number | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface ComplianceDetailTableProps {
|
||||
mismatches: ComplianceMismatch[];
|
||||
}
|
||||
|
||||
const BACKUP_SERVICE_PATTERNS = [
|
||||
/workstation.*backup/i,
|
||||
/w\/ backup/i,
|
||||
/windows server/i,
|
||||
/server virtual/i,
|
||||
/server phys/i,
|
||||
/esxi host/i,
|
||||
/wulf 365 it complete (endpoint|server)/i,
|
||||
/wulf it complete \((server|endpoint)\)/i,
|
||||
];
|
||||
|
||||
function isBackupService(name: string): boolean {
|
||||
return BACKUP_SERVICE_PATTERNS.some((re) => re.test(name));
|
||||
}
|
||||
|
||||
function ContractCoverageModal({
|
||||
contractId,
|
||||
companyName,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
contractId: number | null;
|
||||
companyName: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [data, setData] = useState<{ contract: ContractDetail; services: ContractService[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadedId, setLoadedId] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async (id: number) => {
|
||||
if (loadedId === id) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/data/contracts/${id}/services`);
|
||||
if (!res.ok) throw new Error('Failed to load contract details');
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
setLoadedId(id);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadedId]);
|
||||
|
||||
if (open && contractId && loadedId !== contractId && !loading) {
|
||||
load(contractId);
|
||||
}
|
||||
|
||||
const contract = data?.contract;
|
||||
const services = data?.services ?? [];
|
||||
const backupServices = services.filter((s) => isBackupService(s.display_name));
|
||||
const otherServices = services.filter((s) => !isBackupService(s.display_name));
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
Contract Coverage
|
||||
{contract && (
|
||||
<span className="text-muted-foreground font-normal text-sm ml-1">— {contract.company_name}</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-500 py-4">{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && contract && (
|
||||
<div className="space-y-5">
|
||||
{/* Contract header */}
|
||||
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-base">{contract.contract_name}</p>
|
||||
<p className="text-sm text-muted-foreground">{contract.company_name}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://ww1.autotask.net/contracts/views/contractView.asp?contractID=${contract.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-500 hover:text-blue-600 shrink-0 mt-0.5"
|
||||
>
|
||||
View in Autotask <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
|
||||
{contract.start_date && (
|
||||
<span>Start: {new Date(contract.start_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
{contract.end_date && (
|
||||
<span>End: {new Date(contract.end_date).toLocaleDateString()}</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${contract.status === 1 ? 'bg-green-500' : 'bg-gray-400'}`} />
|
||||
{contract.status === 1 ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backup-relevant services */}
|
||||
{backupServices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-green-600 dark:text-green-400">Backup-Covered Services</p>
|
||||
<ServiceTable services={backupServices} highlight />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other services */}
|
||||
{otherServices.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">All Services ({services.length})</p>
|
||||
<ServiceTable services={otherServices} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{services.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No service lines found for this contract.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceTable({ services, highlight }: { services: ContractService[]; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40">
|
||||
<TableHead className="text-xs">Service</TableHead>
|
||||
<TableHead className="text-xs w-28 text-right">Unit Price</TableHead>
|
||||
<TableHead className="text-xs w-28 text-right">Unit Cost</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map((s) => (
|
||||
<TableRow key={s.id} className={highlight ? 'bg-green-500/5' : undefined}>
|
||||
<TableCell className="text-sm py-2">
|
||||
{highlight && <CheckCircle2 className="inline h-3 w-3 text-green-500 mr-1.5 shrink-0" />}
|
||||
{s.display_name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm py-2 text-right">
|
||||
{s.unit_price != null ? `$${Number(s.unit_price).toFixed(2)}` : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm py-2 text-right text-muted-foreground">
|
||||
{s.unit_cost != null ? `$${Number(s.unit_cost).toFixed(2)}` : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [modalContractId, setModalContractId] = useState<number | null>(null);
|
||||
const [modalCompanyName, setModalCompanyName] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const openModal = (contractId: number, companyName: string | null) => {
|
||||
setModalContractId(contractId);
|
||||
setModalCompanyName(companyName);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const filtered = mismatches.filter((m) => {
|
||||
const matchesSearch =
|
||||
|
|
@ -82,7 +297,7 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
|||
<TableHead>Device</TableHead>
|
||||
<TableHead>Issue</TableHead>
|
||||
<TableHead>Backup UDF</TableHead>
|
||||
<TableHead>Contract</TableHead>
|
||||
<TableHead>Contract Coverage</TableHead>
|
||||
<TableHead>Veeam Workload</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -114,7 +329,34 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
|||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{m.backup_type_udf || '-'}</TableCell>
|
||||
<TableCell className="text-sm">{m.contract_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{m.billing_covered && m.billing_contract_id ? (
|
||||
<button
|
||||
onClick={() => openModal(m.billing_contract_id!, m.company_name)}
|
||||
className="flex items-center gap-1.5 text-left hover:underline cursor-pointer group"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="text-green-700 dark:text-green-400 font-medium group-hover:underline">
|
||||
{m.billing_contract_name || 'Active'}
|
||||
</span>
|
||||
{m.billing_contracted_qty != null && (
|
||||
<span className="text-muted-foreground">({m.billing_contracted_qty} seats)</span>
|
||||
)}
|
||||
</button>
|
||||
) : m.billing_covered ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="text-green-700 dark:text-green-400 font-medium">
|
||||
{m.billing_contract_name || 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
|
||||
<span className="text-red-700 dark:text-red-400">No backup contract</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{m.veeam_workload_name || '-'}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
|
@ -122,6 +364,13 @@ export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps
|
|||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ContractCoverageModal
|
||||
contractId={modalContractId}
|
||||
companyName={modalCompanyName}
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
362
components/backup/contract-coverage-table.tsx
Normal file
362
components/backup/contract-coverage-table.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Search,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Server,
|
||||
Monitor,
|
||||
Mail,
|
||||
Package,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ServiceLine {
|
||||
cs_id: number;
|
||||
contract_id: number;
|
||||
contract_name: string;
|
||||
line_name: string;
|
||||
unit_price: number | null;
|
||||
unit_cost: number | null;
|
||||
category: 'server' | 'workstation' | 'm365' | 'other';
|
||||
}
|
||||
|
||||
interface CoverageRow {
|
||||
company_id: number;
|
||||
company_name: string;
|
||||
contracted: { servers: number; workstations: number; m365: number; other: number };
|
||||
deployed: { servers: number; workstations: number; other: number };
|
||||
lines: ServiceLine[];
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
server: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
|
||||
workstation: 'bg-purple-500/10 text-purple-600 dark:text-purple-400',
|
||||
m365: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
|
||||
other: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
server: 'Server',
|
||||
workstation: 'Workstation',
|
||||
m365: 'M365',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
function DeltaBadge({ contracted, deployed }: { contracted: number; deployed: number }) {
|
||||
const delta = deployed - contracted;
|
||||
if (contracted === 0 && deployed === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
if (delta === 0) return <span className="text-xs text-green-600 dark:text-green-400 font-medium">✓</span>;
|
||||
if (delta > 0)
|
||||
return (
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
+{delta}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span className="text-xs font-medium text-red-600 dark:text-red-400">
|
||||
{delta}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CountCell({
|
||||
contracted,
|
||||
deployed,
|
||||
}: {
|
||||
contracted: number;
|
||||
deployed: number;
|
||||
}) {
|
||||
const delta = deployed - contracted;
|
||||
const hasData = contracted > 0 || deployed > 0;
|
||||
if (!hasData) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
|
||||
const color =
|
||||
delta === 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: delta > 0
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: 'text-red-600 dark:text-red-400';
|
||||
|
||||
return (
|
||||
<span className={`text-sm font-medium tabular-nums ${color}`}>
|
||||
{deployed}/{contracted}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientRow({ row }: { row: CoverageRow }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const hasAnyData =
|
||||
row.contracted.servers + row.contracted.workstations + row.contracted.m365 +
|
||||
row.deployed.servers + row.deployed.workstations > 0;
|
||||
|
||||
// Group service lines by contract
|
||||
const byContract = useMemo(() => {
|
||||
const map = new Map<number, { contract_name: string; lines: ServiceLine[] }>();
|
||||
for (const l of row.lines) {
|
||||
if (!map.has(l.contract_id)) {
|
||||
map.set(l.contract_id, { contract_name: l.contract_name, lines: [] });
|
||||
}
|
||||
map.get(l.contract_id)!.lines.push(l);
|
||||
}
|
||||
return [...map.values()];
|
||||
}, [row.lines]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-muted/40 group"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{/* Expand toggle + Client */}
|
||||
<TableCell className="font-medium py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate max-w-[240px]">{row.company_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Servers deployed/contracted */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
<CountCell contracted={row.contracted.servers} deployed={row.deployed.servers} />
|
||||
</TableCell>
|
||||
|
||||
{/* Workstations deployed/contracted */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
<CountCell contracted={row.contracted.workstations} deployed={row.deployed.workstations} />
|
||||
</TableCell>
|
||||
|
||||
{/* M365 contracted (no Veeam deployed count for M365) */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
{row.contracted.m365 > 0 ? (
|
||||
<span className="text-sm font-medium tabular-nums text-amber-600 dark:text-amber-400">
|
||||
{row.contracted.m365}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* Total service lines */}
|
||||
<TableCell className="text-center py-2.5">
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{row.lines.length}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Expanded detail rows */}
|
||||
{expanded && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={5} className="p-0 border-b">
|
||||
<div className="bg-muted/20 px-4 py-3 space-y-3">
|
||||
{byContract.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-1">No contract service lines found.</p>
|
||||
) : (
|
||||
byContract.map((contract) => {
|
||||
const backupLines = contract.lines.filter(
|
||||
(l) => l.category === 'server' || l.category === 'workstation'
|
||||
);
|
||||
if (backupLines.length === 0) return null;
|
||||
return (
|
||||
<div key={contract.contract_name} className="space-y-1.5">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{contract.contract_name}
|
||||
</p>
|
||||
<div className="rounded border overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 border-b">
|
||||
<th className="px-3 py-1.5 text-left font-medium">Service</th>
|
||||
<th className="px-3 py-1.5 text-left font-medium w-28">Category</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{backupLines.map((line) => (
|
||||
<tr key={line.cs_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5">
|
||||
{line.category === 'server' ? (
|
||||
<Server className="inline h-3 w-3 mr-1.5 text-blue-500 shrink-0" />
|
||||
) : (
|
||||
<Monitor className="inline h-3 w-3 mr-1.5 text-purple-500 shrink-0" />
|
||||
)}
|
||||
{line.line_name}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<span
|
||||
className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${CATEGORY_COLORS[line.category]}`}
|
||||
>
|
||||
{CATEGORY_LABELS[line.category]}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContractCoverageTable() {
|
||||
const [rows, setRows] = useState<CoverageRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<'all' | 'gap' | 'over' | 'matched'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/veeam/contract-coverage')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setRows(d.rows ?? []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
const matchesSearch = r.company_name.toLowerCase().includes(search.toLowerCase());
|
||||
if (!matchesSearch) return false;
|
||||
if (filter === 'all') return true;
|
||||
|
||||
const serverDelta = r.deployed.servers - r.contracted.servers;
|
||||
const wsDelta = r.deployed.workstations - r.contracted.workstations;
|
||||
|
||||
if (filter === 'gap') return serverDelta < 0 || wsDelta < 0;
|
||||
if (filter === 'over') return serverDelta > 0 || wsDelta > 0;
|
||||
if (filter === 'matched')
|
||||
return (
|
||||
r.contracted.servers > 0 || r.contracted.workstations > 0
|
||||
? serverDelta === 0 && wsDelta === 0
|
||||
: false
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}, [rows, search, filter]);
|
||||
|
||||
const FILTERS: { key: typeof filter; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'gap', label: 'Under-deployed' },
|
||||
{ key: 'over', label: 'Over-deployed' },
|
||||
{ key: 'matched', label: 'Matched' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search clients..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1 rounded text-xs font-medium transition-colors border ${
|
||||
filter === f.key
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-transparent text-muted-foreground border-border hover:border-foreground/40'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground ml-auto">{filtered.length} clients</span>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Counts: deployed / contracted</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-green-500" /> Matched
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" /> Over-deployed
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-red-500" /> Gap
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40">
|
||||
<TableHead className="text-xs">Client</TableHead>
|
||||
<TableHead className="text-xs text-center w-28">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Server className="h-3 w-3" /> Servers
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-center w-28">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Monitor className="h-3 w-3" /> Workstations
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-center w-24">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Mail className="h-3 w-3" /> M365
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs text-center w-20">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Package className="h-3 w-3" /> Lines
|
||||
</div>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-12">
|
||||
No clients match the current filter
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((row) => <ClientRow key={row.company_id} row={row} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,9 @@ import {
|
|||
Zap,
|
||||
Radio,
|
||||
Shield,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
NavigationMenu,
|
||||
|
|
@ -61,6 +64,24 @@ const navigationItems: NavItem[] = [
|
|||
icon: HardDrive,
|
||||
description: 'Veeam backup health and compliance'
|
||||
},
|
||||
{
|
||||
title: 'Engagement',
|
||||
icon: Users,
|
||||
children: [
|
||||
{
|
||||
title: 'Overview',
|
||||
href: '/engagement',
|
||||
icon: Users,
|
||||
description: 'Staff activity and engagement metrics',
|
||||
},
|
||||
{
|
||||
title: 'Employee Profile',
|
||||
href: '/engagement/profile',
|
||||
icon: TrendingUp,
|
||||
description: '12-month activity calendar and performance profile',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
icon: Activity,
|
||||
|
|
@ -119,6 +140,12 @@ const navigationItems: NavItem[] = [
|
|||
icon: Zap,
|
||||
description: 'Automated webhook processing workflows'
|
||||
},
|
||||
{
|
||||
title: 'Morning NOC Summary',
|
||||
href: '/admin/morning-summary',
|
||||
icon: Sun,
|
||||
description: 'Daily Zabbix overnight summary posted to Teams channels via webhook'
|
||||
},
|
||||
{
|
||||
title: 'Notification Channels',
|
||||
href: '/admin/workflow/channels',
|
||||
|
|
@ -157,7 +184,7 @@ export function AppNavigation() {
|
|||
|
||||
return (
|
||||
<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 px-6 flex h-16 items-center justify-between">
|
||||
<div className="container mx-auto px-6 flex h-16 items-center justify-between">
|
||||
{/* Logo and App Name */}
|
||||
<Link href="/" className="flex items-center space-x-3 shrink-0">
|
||||
<img
|
||||
|
|
@ -180,7 +207,7 @@ export function AppNavigation() {
|
|||
<>
|
||||
<NavigationMenuTrigger className={cn(
|
||||
"h-9 px-4 py-2",
|
||||
item.children.some(child => isActive(child.href)) && "bg-accent"
|
||||
item.children.some(child => isActive(child.href)) && "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.title}
|
||||
|
|
@ -193,8 +220,8 @@ export function AppNavigation() {
|
|||
<Link
|
||||
href={child.href || '#'}
|
||||
className={cn(
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
|
||||
isActive(child.href) && "bg-accent"
|
||||
"block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-primary/10 hover:text-primary focus:bg-primary/10 focus:text-primary",
|
||||
isActive(child.href) && "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center text-sm font-medium leading-none">
|
||||
|
|
@ -218,7 +245,7 @@ export function AppNavigation() {
|
|||
<NavigationMenuLink className={cn(
|
||||
navigationMenuTriggerStyle(),
|
||||
"h-9",
|
||||
isActive(item.href) && "bg-accent"
|
||||
isActive(item.href) && "bg-primary text-primary-foreground"
|
||||
)}>
|
||||
{item.icon && <item.icon className="w-4 h-4 mr-2" />}
|
||||
{item.title}
|
||||
|
|
@ -255,7 +282,7 @@ interface PageHeaderProps {
|
|||
export function PageHeader({ title, description, breadcrumbs, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container px-6 py-4">
|
||||
<div className="container mx-auto px-6 py-4">
|
||||
{/* Breadcrumbs */}
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="flex items-center space-x-2 text-sm text-muted-foreground mb-2">
|
||||
|
|
|
|||
713
components/zabbix/host-manager.tsx
Normal file
713
components/zabbix/host-manager.tsx
Normal file
|
|
@ -0,0 +1,713 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
MinusCircle,
|
||||
Search,
|
||||
List,
|
||||
Plus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ZabbixTag { tag: string; value: string; }
|
||||
interface ZabbixMacro { macro: string; value: string; description?: string; }
|
||||
interface ZabbixGroup { groupid: string; name: string; }
|
||||
interface ZabbixInterface { type: number; main: number; useip: number; ip: string; dns: string; port: string; }
|
||||
|
||||
interface ZabbixHostRow {
|
||||
hostid: string;
|
||||
host: string;
|
||||
name: string;
|
||||
status: string;
|
||||
description?: string;
|
||||
interfaces?: ZabbixInterface[];
|
||||
groups?: ZabbixGroup[];
|
||||
macros?: ZabbixMacro[];
|
||||
tags?: ZabbixTag[];
|
||||
rmmMatched: boolean | null;
|
||||
sourceTag: string | null;
|
||||
}
|
||||
|
||||
interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface HostManagerProps {
|
||||
companies: Company[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function primaryIp(host: ZabbixHostRow): string {
|
||||
const iface = host.interfaces?.find((i) => i.main === 1 || (i.main as any) === '1');
|
||||
return iface?.ip ?? '—';
|
||||
}
|
||||
|
||||
function tagValue(host: ZabbixHostRow, key: string): string | null {
|
||||
return host.tags?.find((t) => t.tag === key)?.value ?? null;
|
||||
}
|
||||
|
||||
function macroValue(host: ZabbixHostRow, key: string): string | null {
|
||||
return host.macros?.find((m) => m.macro === key)?.value ?? null;
|
||||
}
|
||||
|
||||
function clientLabel(host: ZabbixHostRow): string | null {
|
||||
return tagValue(host, 'client') ?? macroValue(host, '{$AUTOTASK_COMPANY_NAME}');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Modal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditModalProps {
|
||||
host: ZabbixHostRow;
|
||||
companies: Company[];
|
||||
onClose: () => void;
|
||||
onSaved: (updated: ZabbixHostRow) => void;
|
||||
}
|
||||
|
||||
function EditModal({ host, companies, onClose, onSaved }: EditModalProps) {
|
||||
const [name, setName] = useState(host.name);
|
||||
const [ip, setIp] = useState(primaryIp(host));
|
||||
const [description, setDescription] = useState(host.description ?? '');
|
||||
const [companyId, setCompanyId] = useState<string>(() => {
|
||||
const id = macroValue(host, '{$AUTOTASK_COMPANY_ID}');
|
||||
return id ?? 'none';
|
||||
});
|
||||
const [tags, setTags] = useState<ZabbixTag[]>(() => host.tags ? [...host.tags] : []);
|
||||
const [macros, setMacros] = useState<ZabbixMacro[]>(() => host.macros ? [...host.macros] : []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rebuildFromClient, setRebuildFromClient] = useState(false);
|
||||
|
||||
const ipv4Valid = /^(\d{1,3}\.){3}\d{1,3}$/.test(ip);
|
||||
|
||||
const addTag = () => setTags((t) => [...t, { tag: '', value: '' }]);
|
||||
const removeTag = (i: number) => setTags((t) => t.filter((_, idx) => idx !== i));
|
||||
const updateTag = (i: number, field: 'tag' | 'value', val: string) =>
|
||||
setTags((t) => t.map((item, idx) => idx === i ? { ...item, [field]: val } : item));
|
||||
|
||||
const addMacro = () => setMacros((m) => [...m, { macro: '{$}', value: '', description: '' }]);
|
||||
const removeMacro = (i: number) => setMacros((m) => m.filter((_, idx) => idx !== i));
|
||||
const updateMacro = (i: number, field: keyof ZabbixMacro, val: string) =>
|
||||
setMacros((m) => m.map((item, idx) => idx === i ? { ...item, [field]: val } : item));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const resp = await fetch(`/api/zabbix/hosts/${host.hostid}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: name.trim(),
|
||||
ip: ip.trim(),
|
||||
description,
|
||||
companyId: companyId && companyId !== 'none' ? Number(companyId) : null,
|
||||
tags,
|
||||
macros,
|
||||
rebuildFromClient,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
toast.error(data.error ?? 'Save failed');
|
||||
return;
|
||||
}
|
||||
toast.success(`Host "${name}" saved`);
|
||||
// Return updated row (optimistic — caller will refresh)
|
||||
onSaved({ ...host, name, description, tags, macros });
|
||||
} catch (err) {
|
||||
toast.error('Save failed: ' + String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Host</DialogTitle>
|
||||
<DialogDescription className="font-mono text-xs">{host.hostid}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Name + IP */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Display Name</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>IP Address</Label>
|
||||
<Input
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
className={`font-mono ${ip && !ipv4Valid ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
{ip && !ipv4Valid && <p className="text-xs text-destructive">Invalid IPv4</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Description</Label>
|
||||
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</div>
|
||||
|
||||
{/* Client */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Client (Autotask)</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={companyId} onValueChange={setCompanyId}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue placeholder="No client" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No client</SelectItem>
|
||||
{companies.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rebuildFromClient}
|
||||
onChange={(e) => setRebuildFromClient(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Rebuild macros/tags/groups from client
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Check "Rebuild" to re-run the full ISP lookup and regenerate all groups, macros, and tags
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Tags</Label>
|
||||
<Button variant="ghost" size="sm" onClick={addTag} className="gap-1 h-7 text-xs">
|
||||
<Plus className="w-3 h-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{tags.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="tag"
|
||||
value={t.tag}
|
||||
onChange={(e) => updateTag(i, 'tag', e.target.value)}
|
||||
className="w-36 text-sm h-8"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={t.value}
|
||||
onChange={(e) => updateTag(i, 'value', e.target.value)}
|
||||
className="flex-1 text-sm h-8"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeTag(i)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{tags.length === 0 && <p className="text-xs text-muted-foreground italic">No tags</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Macros */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Macros</Label>
|
||||
<Button variant="ghost" size="sm" onClick={addMacro} className="gap-1 h-7 text-xs">
|
||||
<Plus className="w-3 h-3" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{macros.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="{$KEY}"
|
||||
value={m.macro}
|
||||
onChange={(e) => updateMacro(i, 'macro', e.target.value)}
|
||||
className="w-52 font-mono text-sm h-8"
|
||||
/>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={m.value}
|
||||
onChange={(e) => updateMacro(i, 'value', e.target.value)}
|
||||
className="flex-1 text-sm h-8"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeMacro(i)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{macros.length === 0 && <p className="text-xs text-muted-foreground italic">No macros</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !name.trim() || !ipv4Valid} className="gap-2">
|
||||
{saving ? <><Loader2 className="w-4 h-4 animate-spin" /> Saving…</> : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete Confirm Dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeleteDialogProps {
|
||||
count: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
deleting: boolean;
|
||||
}
|
||||
|
||||
function DeleteDialog({ count, onConfirm, onCancel, deleting }: DeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {count} host{count !== 1 ? 's' : ''}?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently remove {count === 1 ? 'this host' : `these ${count} hosts`} from Zabbix. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={deleting}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} disabled={deleting} className="gap-2">
|
||||
{deleting ? <><Loader2 className="w-4 h-4 animate-spin" /> Deleting…</> : <><Trash2 className="w-4 h-4" /> Delete</>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main HostManager component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function HostManager({ companies }: HostManagerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZabbixHostRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Filters
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterSource, setFilterSource] = useState<'all' | 'datto-rmm' | 'manual'>('all');
|
||||
const [filterRmm, setFilterRmm] = useState<'all' | 'matched' | 'unmatched'>('all');
|
||||
const [filterClient, setFilterClient] = useState<string>('all');
|
||||
|
||||
// Selection
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// Edit / delete
|
||||
const [editHost, setEditHost] = useState<ZabbixHostRow | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const loadHosts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const resp = await fetch('/api/zabbix/hosts');
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error ?? 'Failed to load');
|
||||
setHosts(data.hosts ?? []);
|
||||
setLoaded(true);
|
||||
setSelected(new Set());
|
||||
} catch (err) {
|
||||
toast.error('Failed to load hosts: ' + String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true);
|
||||
if (!loaded) loadHosts();
|
||||
};
|
||||
|
||||
// Filtered hosts
|
||||
const filtered = hosts.filter((h) => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
const ip = primaryIp(h).toLowerCase();
|
||||
if (
|
||||
!h.name.toLowerCase().includes(q) &&
|
||||
!ip.includes(q) &&
|
||||
!(clientLabel(h) ?? '').toLowerCase().includes(q)
|
||||
) return false;
|
||||
}
|
||||
if (filterSource !== 'all' && h.sourceTag !== filterSource) return false;
|
||||
if (filterRmm === 'matched' && h.rmmMatched !== true) return false;
|
||||
if (filterRmm === 'unmatched' && h.rmmMatched !== false) return false;
|
||||
if (filterClient !== 'all') {
|
||||
const cl = clientLabel(h);
|
||||
if (!cl || !cl.toLowerCase().includes(filterClient.toLowerCase())) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// All-select toggle
|
||||
const allSelected = filtered.length > 0 && filtered.every((h) => selected.has(h.hostid));
|
||||
const someSelected = filtered.some((h) => selected.has(h.hostid));
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelected((s) => { const n = new Set(s); filtered.forEach((h) => n.delete(h.hostid)); return n; });
|
||||
} else {
|
||||
setSelected((s) => { const n = new Set(s); filtered.forEach((h) => n.add(h.hostid)); return n; });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelected((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
|
||||
};
|
||||
|
||||
const selectedCount = selected.size;
|
||||
|
||||
// Bulk delete
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const hostids = Array.from(selected);
|
||||
const resp = await fetch('/api/zabbix/hosts', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hostids }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error ?? 'Delete failed');
|
||||
toast.success(`Deleted ${data.deleted} host${data.deleted !== 1 ? 's' : ''}`);
|
||||
setHosts((h) => h.filter((host) => !selected.has(host.hostid)));
|
||||
setSelected(new Set());
|
||||
setShowDeleteDialog(false);
|
||||
} catch (err) {
|
||||
toast.error('Delete failed: ' + String(err));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Unique client names for filter dropdown
|
||||
const clientNames = Array.from(
|
||||
new Set(hosts.map((h) => clientLabel(h)).filter(Boolean) as string[])
|
||||
).sort();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="pb-4 cursor-pointer select-none"
|
||||
onClick={() => (open ? setOpen(false) : handleOpen())}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{open ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}
|
||||
<List className="w-4 h-4" />
|
||||
<CardTitle className="text-base">Host Manager</CardTitle>
|
||||
{loaded && (
|
||||
<Badge variant="secondary" className="text-xs">{hosts.length}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="mt-0">
|
||||
Browse, filter, edit and delete existing Zabbix hosts
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{open && (
|
||||
<CardContent className="pt-0 space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search name, IP, client…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 w-56 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source filter */}
|
||||
<Select value={filterSource} onValueChange={(v) => setFilterSource(v as any)}>
|
||||
<SelectTrigger className="w-36 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All sources</SelectItem>
|
||||
<SelectItem value="datto-rmm">datto-rmm</SelectItem>
|
||||
<SelectItem value="manual">manual</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* RMM match filter */}
|
||||
<Select value={filterRmm} onValueChange={(v) => setFilterRmm(v as any)}>
|
||||
<SelectTrigger className="w-40 h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All RMM status</SelectItem>
|
||||
<SelectItem value="matched">RMM matched</SelectItem>
|
||||
<SelectItem value="unmatched">RMM unmatched</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Client filter */}
|
||||
<Select value={filterClient} onValueChange={setFilterClient}>
|
||||
<SelectTrigger className="w-48 h-8 text-sm">
|
||||
<SelectValue placeholder="All clients" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All clients</SelectItem>
|
||||
{clientNames.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Bulk delete toolbar */}
|
||||
{selectedCount > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-destructive/5 border border-destructive/20">
|
||||
<span className="text-sm font-medium text-destructive">{selectedCount} selected</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={loadHosts} disabled={loading} className="gap-1.5 h-8">
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result count */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading ? 'Loading…' : `${filtered.length} of ${hosts.length} host${hosts.length !== 1 ? 's' : ''}`}
|
||||
{filterRmm === 'unmatched' && !loading && (
|
||||
<span className="ml-2 text-amber-600 font-medium">— {filtered.length} not matched to an RMM site</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<div className="max-h-[560px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
ref={(el) => { if (el) (el as any).indeterminate = someSelected && !allSelected; }}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>Client</TableHead>
|
||||
<TableHead>ISP</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>RMM</TableHead>
|
||||
<TableHead>Groups</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||
Loading hosts…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12 text-muted-foreground text-sm">
|
||||
No hosts match the current filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && filtered.map((h) => {
|
||||
const isUnmatched = h.rmmMatched === false;
|
||||
return (
|
||||
<TableRow
|
||||
key={h.hostid}
|
||||
className={`${selected.has(h.hostid) ? 'bg-muted/40' : ''} ${isUnmatched ? 'border-l-2 border-l-amber-400 bg-amber-500/5' : ''}`}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.has(h.hostid)}
|
||||
onCheckedChange={() => toggleOne(h.hostid)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-sm max-w-[200px]">
|
||||
<div className="truncate" title={h.name}>{h.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono truncate">{h.host !== h.name ? h.host : ''}</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{primaryIp(h)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-[160px]">
|
||||
<span className="truncate block" title={clientLabel(h) ?? undefined}>
|
||||
{clientLabel(h) ?? <span className="italic opacity-50">—</span>}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm max-w-[160px]">
|
||||
<div className="truncate" title={tagValue(h, 'isp') ?? undefined}>
|
||||
{tagValue(h, 'isp') ?? <span className="text-muted-foreground">—</span>}
|
||||
</div>
|
||||
{tagValue(h, 'asn') && (
|
||||
<div className="text-xs text-muted-foreground">{tagValue(h, 'asn')}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.sourceTag ? (
|
||||
<Badge variant={h.sourceTag === 'datto-rmm' ? 'secondary' : 'outline'} className="text-xs">
|
||||
{h.sourceTag}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{h.rmmMatched === true && (
|
||||
<span title="Matched to an RMM site">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
</span>
|
||||
)}
|
||||
{h.rmmMatched === false && (
|
||||
<span title="No matching RMM site found">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
{h.rmmMatched === null && (
|
||||
<span title="Not an RMM-sourced host">
|
||||
<MinusCircle className="w-4 h-4 text-muted-foreground/40" />
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(h.groups ?? []).slice(0, 3).map((g) => (
|
||||
<Badge key={g.groupid} variant="outline" className="text-xs px-1.5 py-0">
|
||||
{g.name}
|
||||
</Badge>
|
||||
))}
|
||||
{(h.groups?.length ?? 0) > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0">
|
||||
+{(h.groups?.length ?? 0) - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setEditHost(h)}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Edit modal */}
|
||||
{editHost && (
|
||||
<EditModal
|
||||
host={editHost}
|
||||
companies={companies}
|
||||
onClose={() => setEditHost(null)}
|
||||
onSaved={(updated) => {
|
||||
setHosts((hs) => hs.map((h) => h.hostid === updated.hostid ? updated : h));
|
||||
setEditHost(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirm */}
|
||||
{showDeleteDialog && (
|
||||
<DeleteDialog
|
||||
count={selectedCount}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
deleting={deleting}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue