- 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
713 lines
28 KiB
TypeScript
713 lines
28 KiB
TypeScript
'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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|