sistema-de-chamados/src/components/admin/machines/admin-machines-overview.tsx

1932 lines
90 KiB
TypeScript

"use client"
import { useEffect, useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { format, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { toast } from "sonner"
import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Apple, Terminal } from "lucide-react"
import { api } from "@/convex/_generated/api"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useAuth } from "@/lib/auth-client"
import type { Id } from "@/convex/_generated/dataModel"
type MachineMetrics = Record<string, unknown> | null
type MachineLabel = {
id?: number | string
name?: string
}
type MachineSoftware = {
name?: string
version?: string
source?: string
}
type DetailLineProps = { label: string; value?: string | number | null; classNameValue?: string }
type GpuAdapter = {
name?: string
vendor?: string
driver?: string
memoryBytes?: number
}
type LinuxLsblkEntry = {
name?: string
mountPoint?: string
mountpoint?: string
fs?: string
fstype?: string
sizeBytes?: number
size?: number
}
type LinuxSmartEntry = {
smart_status?: { passed?: boolean }
model_name?: string
model_family?: string
serial_number?: string
device?: { name?: string }
}
type LinuxExtended = {
lsblk?: LinuxLsblkEntry[]
lspci?: string
lsusb?: string
pciList?: Array<{ text: string }>
usbList?: Array<{ text: string }>
smart?: LinuxSmartEntry[]
}
type WindowsCpuInfo = {
Name?: string
Manufacturer?: string
SocketDesignation?: string
NumberOfCores?: number
NumberOfLogicalProcessors?: number
L2CacheSize?: number
L3CacheSize?: number
MaxClockSpeed?: number
}
type WindowsMemoryModule = {
BankLabel?: string
Capacity?: number
Manufacturer?: string
PartNumber?: string
SerialNumber?: string
ConfiguredClockSpeed?: number
Speed?: number
ConfiguredVoltage?: number
}
type WindowsVideoController = {
Name?: string
AdapterRAM?: number
DriverVersion?: string
PNPDeviceID?: string
}
type WindowsDiskEntry = {
Model?: string
SerialNumber?: string
Size?: number
InterfaceType?: string
MediaType?: string
}
type WindowsOsInfo = {
ProductName?: string
CurrentBuild?: string | number
CurrentBuildNumber?: string | number
DisplayVersion?: string
ReleaseId?: string
EditionID?: string
LicenseStatus?: number
IsActivated?: boolean
}
type WindowsExtended = {
software?: MachineSoftware[]
services?: Array<{ name?: string; status?: string; displayName?: string }>
defender?: Record<string, unknown>
hotfix?: Array<Record<string, unknown>>
cpu?: WindowsCpuInfo | WindowsCpuInfo[]
baseboard?: Record<string, unknown> | Array<Record<string, unknown>>
bios?: Record<string, unknown> | Array<Record<string, unknown>>
memoryModules?: WindowsMemoryModule[]
videoControllers?: WindowsVideoController[]
disks?: WindowsDiskEntry[]
osInfo?: WindowsOsInfo
}
type MacExtended = {
systemProfiler?: Record<string, unknown>
packages?: string[]
launchctl?: string
}
type NetworkInterface = { name?: string; mac?: string; ip?: string }
type MachineInventory = {
hardware?: {
vendor?: string
model?: string
serial?: string
cpuType?: string
physicalCores?: number
logicalCores?: number
memoryBytes?: number
memory?: number
primaryGpu?: GpuAdapter
gpus?: GpuAdapter[]
}
network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | NetworkInterface[]
software?: MachineSoftware[]
labels?: MachineLabel[]
fleet?: {
id?: number | string
teamId?: number | string
detailUpdatedAt?: string
osqueryVersion?: string
}
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }>
extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
services?: Array<{ name?: string; status?: string; displayName?: string }>
collaborator?: { email?: string; name?: string; role?: string }
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object") return null
return value as Record<string, unknown>
}
function readString(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const raw = record[key]
if (typeof raw === "string" && raw.trim().length > 0) {
return raw
}
}
return undefined
}
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | undefined {
for (const key of keys) {
const raw = record[key]
if (typeof raw === "number" && Number.isFinite(raw)) {
return raw
}
if (typeof raw === "string") {
const trimmed = raw.trim()
if (!trimmed) continue
const parsed = Number(trimmed)
if (!Number.isNaN(parsed)) return parsed
const digits = trimmed.replace(/[^0-9.]/g, "")
if (digits) {
const fallback = Number(digits)
if (!Number.isNaN(fallback)) return fallback
}
}
}
return undefined
}
function parseBytesLike(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return undefined
const normalized = trimmed.replace(",", ".")
const match = normalized.match(/^([\d.]+)\s*(ti|tb|tib|gb|gib|mb|mib|kb|kib|b)?$/i)
if (match) {
const amount = Number(match[1])
if (Number.isNaN(amount)) return undefined
const unit = match[2]?.toLowerCase()
const base = 1024
const unitMap: Record<string, number> = {
b: 1,
kb: base,
kib: base,
mb: base ** 2,
mib: base ** 2,
gb: base ** 3,
gib: base ** 3,
tb: base ** 4,
tib: base ** 4,
ti: base ** 4,
}
if (unit) {
const multiplier = unitMap[unit]
if (multiplier) {
return amount * multiplier
}
}
return amount
}
const digits = normalized.replace(/[^0-9.]/g, "")
if (digits) {
const fallback = Number(digits)
if (!Number.isNaN(fallback)) return fallback
}
}
return undefined
}
function deriveVendor(record: Record<string, unknown>): string | undefined {
const direct = readString(record, "vendor", "Vendor", "AdapterCompatibility")
if (direct) return direct
const pnp = readString(record, "PNPDeviceID")
if (!pnp) return undefined
const match = pnp.match(/VEN_([0-9A-F]{4})/i)
if (match) {
const vendorCode = match[1].toUpperCase()
const vendorMap: Record<string, string> = {
"10DE": "NVIDIA",
"1002": "AMD",
"1022": "AMD",
"8086": "Intel",
"8087": "Intel",
"1AF4": "Red Hat",
}
return vendorMap[vendorCode] ?? `VEN_${vendorCode}`
}
const segments = pnp.split("\\")
const last = segments.pop()
return last && last.trim().length > 0 ? last : pnp
}
function normalizeGpuSource(value: unknown): GpuAdapter | null {
if (typeof value === "string") {
const name = value.trim()
return name ? { name } : null
}
const record = toRecord(value)
if (!record) return null
const name = readString(record, "name", "Name", "_name", "AdapterCompatibility")
const vendor = deriveVendor(record)
const driver = readString(record, "driver", "DriverVersion", "driverVersion")
const memoryBytes =
readNumber(record, "memoryBytes", "MemoryBytes", "AdapterRAM", "VRAM", "vramBytes") ??
parseBytesLike(record["AdapterRAM"] ?? record["VRAM"] ?? record["vram"])
if (!name && !vendor && !driver && memoryBytes === undefined) {
return null
}
return { name, vendor, driver, memoryBytes }
}
function uniqueBy<T>(items: T[], keyFn: (item: T) => string): T[] {
const seen = new Set<string>()
const result: T[] = []
items.forEach((item) => {
const key = keyFn(item)
if (key && !seen.has(key)) {
seen.add(key)
result.push(item)
}
})
return result
}
export type MachinesQueryItem = {
id: string
tenantId: string
hostname: string
companyId: string | null
companySlug: string | null
osName: string | null
osVersion: string | null
architecture: string | null
macAddresses: string[]
serialNumbers: string[]
authUserId: string | null
authEmail: string | null
persona: string | null
assignedUserId: string | null
assignedUserEmail: string | null
assignedUserName: string | null
assignedUserRole: string | null
status: string | null
lastHeartbeatAt: number | null
heartbeatAgeMs: number | null
registeredBy: string | null
createdAt: number
updatedAt: number
token: {
expiresAt: number
lastUsedAt: number | null
usageCount: number
} | null
metrics: MachineMetrics
inventory: MachineInventory | null
postureAlerts?: Array<Record<string, unknown>> | null
lastPostureAt?: number | null
}
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
return (
(useQuery(api.machines.listByTenant, {
tenantId,
includeMetadata: true,
}) ?? []) as MachinesQueryItem[]
)
}
const statusLabels: Record<string, string> = {
online: "Online",
offline: "Offline",
maintenance: "Manutenção",
blocked: "Bloqueada",
unknown: "Desconhecida",
}
const statusClasses: Record<string, string> = {
online: "border-emerald-500/20 bg-emerald-500/15 text-emerald-600",
offline: "border-rose-500/20 bg-rose-500/15 text-rose-600",
maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600",
blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600",
unknown: "border-slate-300 bg-slate-200 text-slate-700",
}
function formatRelativeTime(date?: Date | null) {
if (!date) return "Nunca"
try {
return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR })
} catch {
return "—"
}
}
function formatDate(date?: Date | null) {
if (!date) return "—"
return format(date, "dd/MM/yyyy HH:mm")
}
function formatBytes(bytes?: number | null) {
if (!bytes || Number.isNaN(bytes)) return "—"
const units = ["B", "KB", "MB", "GB", "TB"]
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
function formatPercent(value?: number | null) {
if (value === null || value === undefined || Number.isNaN(value)) return "—"
const normalized = value > 1 ? value : value * 100
return `${normalized.toFixed(0)}%`
}
function readBool(source: unknown, key: string): boolean | undefined {
if (!source || typeof source !== "object") return undefined
const value = (source as Record<string, unknown>)[key]
return typeof value === "boolean" ? value : undefined
}
function getStatusVariant(status?: string | null) {
if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown }
const normalized = status.toLowerCase()
return {
label: statusLabels[normalized] ?? status,
className: statusClasses[normalized] ?? statusClasses.unknown,
}
}
function OsIcon({ osName }: { osName?: string | null }) {
const name = (osName ?? "").toLowerCase()
if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return <Apple className="size-4 text-black" />
if (name.includes("linux")) return <Terminal className="size-4 text-black" />
// fallback para Windows/outros como monitor genérico
return <Monitor className="size-4 text-black" />
}
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
const machines = useMachinesQuery(tenantId)
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<string>("all")
const [osFilter, setOsFilter] = useState<string>("all")
const [companyQuery, setCompanyQuery] = useState<string>("")
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
const { convexUserId } = useAuth()
const companies = useQuery(
convexUserId ? api.companies.list : "skip",
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : ("skip" as const)
) as Array<{ id: string; name: string; slug?: string }> | undefined
const companyNameBySlug = useMemo(() => {
const map = new Map<string, string>()
;(companies ?? []).forEach((c) => c.slug && map.set(c.slug, c.name))
return map
}, [companies])
const osOptions = useMemo(() => {
const set = new Set<string>()
machines.forEach((m) => m.osName && set.add(m.osName))
return Array.from(set).sort()
}, [machines])
const companyNameOptions = useMemo(() => (companies ?? []).map((c) => c.name).sort((a,b)=>a.localeCompare(b,"pt-BR")), [companies])
const filteredMachines = useMemo(() => {
const text = q.trim().toLowerCase()
return machines.filter((m) => {
if (onlyAlerts && !(Array.isArray(m.postureAlerts) && m.postureAlerts.length > 0)) return false
if (statusFilter !== "all") {
const s = (m.status ?? "unknown").toLowerCase()
if (s !== statusFilter) return false
}
if (osFilter !== "all" && (m.osName ?? "").toLowerCase() !== osFilter.toLowerCase()) return false
if (companyQuery && companyQuery.trim().length > 0) {
const name = companyNameBySlug.get(m.companySlug ?? "")?.toLowerCase() ?? ""
if (!name.includes(companyQuery.trim().toLowerCase())) return false
}
if (!text) return true
const hay = [
m.hostname,
m.authEmail ?? "",
(m.macAddresses ?? []).join(" "),
(m.serialNumbers ?? []).join(" "),
]
.join(" ")
.toLowerCase()
return hay.includes(text)
})
}, [machines, q, statusFilter, osFilter, companyQuery, onlyAlerts, companyNameBySlug])
return (
<div className="grid gap-6">
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Máquinas registradas</CardTitle>
<CardDescription>Sincronizadas via agente local ou Fleet. Atualiza em tempo real.</CardDescription>
</CardHeader>
<CardContent className="overflow-hidden">
<div className="mb-3 flex flex-wrap items-center gap-2">
<div className="min-w-[220px] flex-1">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Buscar hostname, e-mail, MAC, serial..." />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-36">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos status</SelectItem>
<SelectItem value="online">Online</SelectItem>
<SelectItem value="offline">Offline</SelectItem>
<SelectItem value="unknown">Desconhecido</SelectItem>
</SelectContent>
</Select>
<Select value={osFilter} onValueChange={setOsFilter}>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="Sistema" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos sistemas</SelectItem>
{osOptions.map((os) => (
<SelectItem key={os} value={os}>{os}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Input
value={companyQuery}
onChange={(e) => setCompanyQuery(e.target.value)}
placeholder="Buscar empresa"
className="min-w-[220px]"
/>
{companyQuery && companyNameOptions.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase())).slice(0,6).length > 0 ? (
<div className="absolute z-10 mt-1 max-h-52 w-full overflow-auto rounded-md border bg-white p-1 shadow-sm">
{companyNameOptions
.filter((c) => c.toLowerCase().includes(companyQuery.toLowerCase()))
.slice(0, 8)
.map((c) => (
<button
key={c}
type="button"
onClick={() => setCompanyQuery(c)}
className="w-full rounded px-2 py-1 text-left text-sm hover:bg-slate-100"
>
{c}
</button>
))}
</div>
) : null}
</div>
<label className="inline-flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-3 py-1.5 text-sm">
<Checkbox checked={onlyAlerts} onCheckedChange={(v) => setOnlyAlerts(Boolean(v))} />
<span>Somente com alertas</span>
</label>
<Button variant="outline" onClick={() => { setQ(""); setStatusFilter("all"); setOsFilter("all"); setCompanyQuery(""); setOnlyAlerts(false) }}>Limpar</Button>
</div>
{machines.length === 0 ? (
<EmptyState />
) : (
<MachinesGrid machines={filteredMachines} companyNameBySlug={companyNameBySlug} />
)}
</CardContent>
</Card>
</div>
)
}
function MachineStatusBadge({ status }: { status?: string | null }) {
const { label, className } = getStatusVariant(status)
const s = String(status ?? "").toLowerCase()
const colorClass =
s === "online"
? "bg-emerald-500"
: s === "offline"
? "bg-rose-500"
: s === "maintenance"
? "bg-amber-500"
: s === "blocked"
? "bg-orange-500"
: "bg-slate-400"
const ringClass =
s === "online"
? "bg-emerald-400/30"
: s === "offline"
? "bg-rose-400/30"
: s === "maintenance"
? "bg-amber-400/30"
: s === "blocked"
? "bg-orange-400/30"
: "bg-slate-300/30"
const isOnline = s === "online"
return (
<Badge className={cn("inline-flex h-9 items-center gap-5 rounded-full border border-slate-200 px-3 text-sm font-semibold", className)}>
<span className="relative inline-flex items-center">
<span className={cn("size-2 rounded-full", colorClass)} />
{isOnline ? (
<span className={cn("absolute left-1/2 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full animate-ping [animation-duration:2s]", ringClass)} />
) : null}
</span>
{label}
</Badge>
)
}
function EmptyState() {
return (
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed border-slate-300 bg-slate-50/50 py-12 text-center">
<ServerCog className="size-10 text-slate-400" />
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-600">Nenhuma máquina registrada ainda</p>
<p className="text-sm text-muted-foreground">
Execute o agente local ou o webhook do Fleet para registrar as máquinas do tenant.
</p>
</div>
</div>
)
}
type MachineDetailsProps = {
machine: MachinesQueryItem | null
}
export function MachineDetails({ machine }: MachineDetailsProps) {
const { convexUserId } = useAuth()
const router = useRouter()
// Company name lookup (by slug)
const companyQueryArgs = convexUserId && machine ? { tenantId: machine.tenantId, viewerId: convexUserId as Id<"users"> } : undefined
const companies = useQuery(
api.companies.list,
companyQueryArgs ?? ("skip" as const)
) as Array<{ id: string; name: string; slug?: string }> | undefined
const metadata = machine?.inventory ?? null
const metrics = machine?.metrics ?? null
const hardware = metadata?.hardware
const network = metadata?.network ?? null
const networkInterfaces = Array.isArray(network) ? network : null
const networkSummary = !Array.isArray(network) && network ? network : null
const software = metadata?.software ?? null
const labels = metadata?.labels ?? null
const fleet = metadata?.fleet ?? null
const disks = Array.isArray(metadata?.disks) ? metadata.disks : []
const extended = metadata?.extended ?? null
const linuxExt = extended?.linux ?? null
const windowsExt = extended?.windows ?? null
const macosExt = extended?.macos ?? null
const windowsMemoryModulesRaw = windowsExt?.memoryModules
const windowsVideoControllersRaw = windowsExt?.videoControllers
const windowsDiskEntriesRaw = windowsExt?.disks
const windowsMemoryModules = Array.isArray(windowsMemoryModulesRaw)
? windowsMemoryModulesRaw
: windowsMemoryModulesRaw && typeof windowsMemoryModulesRaw === "object"
? [windowsMemoryModulesRaw]
: []
const windowsVideoControllers = Array.isArray(windowsVideoControllersRaw)
? windowsVideoControllersRaw
: windowsVideoControllersRaw && typeof windowsVideoControllersRaw === "object"
? [windowsVideoControllersRaw]
: []
const windowsDiskEntries = Array.isArray(windowsDiskEntriesRaw)
? windowsDiskEntriesRaw
: windowsDiskEntriesRaw && typeof windowsDiskEntriesRaw === "object"
? [windowsDiskEntriesRaw]
: []
const linuxLsblk = linuxExt?.lsblk ?? []
const linuxSmartEntries = linuxExt?.smart ?? []
const normalizedHardwareGpus = Array.isArray(hardware?.gpus)
? hardware.gpus.map((gpu) => normalizeGpuSource(gpu)).filter((gpu): gpu is GpuAdapter => Boolean(gpu))
: []
const hardwarePrimaryGpu = hardware?.primaryGpu ? normalizeGpuSource(hardware.primaryGpu) : null
const windowsCpuRaw = windowsExt?.cpu
const winCpu = windowsCpuRaw
? (Array.isArray(windowsCpuRaw) ? windowsCpuRaw[0] ?? null : windowsCpuRaw)
: null
const winMemTotal = windowsMemoryModules.reduce((acc, module) => acc + (parseBytesLike(module?.Capacity) ?? 0), 0)
const normalizedWindowsGpus = windowsVideoControllers
.map((controller) => normalizeGpuSource(controller))
.filter((gpu): gpu is GpuAdapter => Boolean(gpu))
const combinedGpus = uniqueBy(
[
...(hardwarePrimaryGpu ? [hardwarePrimaryGpu] : []),
...normalizedHardwareGpus,
...normalizedWindowsGpus,
],
(gpu) => `${gpu.name ?? ""}|${gpu.vendor ?? ""}|${gpu.driver ?? ""}`
)
const displayGpus = [...combinedGpus].sort(
(a, b) => (b.memoryBytes ?? 0) - (a.memoryBytes ?? 0)
)
const primaryGpu = hardwarePrimaryGpu ?? displayGpus[0] ?? null
const windowsPrimaryGpu = [...normalizedWindowsGpus].sort(
(a, b) => (b.memoryBytes ?? 0) - (a.memoryBytes ?? 0)
)[0] ?? null
const windowsCpuDetails = windowsCpuRaw
? Array.isArray(windowsCpuRaw)
? windowsCpuRaw
: [windowsCpuRaw]
: []
const windowsServices = windowsExt?.services ?? []
const windowsSoftware = windowsExt?.software ?? []
const winDiskStats = windowsDiskEntries.length > 0
? {
count: windowsDiskEntries.length,
total: windowsDiskEntries.reduce((acc, disk) => acc + (parseBytesLike(disk?.Size) ?? 0), 0),
}
: { count: 0, total: 0 }
const lastHeartbeatDate = machine?.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
const tokenExpiry = machine?.token?.expiresAt ? new Date(machine.token.expiresAt) : null
const tokenLastUsed = machine?.token?.lastUsedAt ? new Date(machine.token.lastUsedAt) : null
const copyEmail = async () => {
if (!machine?.authEmail) return
try {
await navigator.clipboard.writeText(machine.authEmail)
toast.success("E-mail da máquina copiado.")
} catch {
toast.error("Não foi possível copiar o e-mail da máquina.")
}
}
// collaborator (from machine assignment or metadata)
type Collaborator = { email?: string; name?: string; role?: string }
const collaborator: Collaborator | null = useMemo(() => {
if (machine?.assignedUserEmail) {
return {
email: machine.assignedUserEmail ?? undefined,
name: machine.assignedUserName ?? undefined,
role: machine.persona ?? machine.assignedUserRole ?? undefined,
}
}
if (!metadata || typeof metadata !== "object") return null
const inv = metadata as Record<string, unknown>
const c = inv["collaborator"]
if (c && typeof c === "object") {
const base = c as Record<string, unknown>
return {
email: typeof base.email === "string" ? base.email : undefined,
name: typeof base.name === "string" ? base.name : undefined,
role: typeof base.role === "string" ? (base.role as string) : undefined,
}
}
return null
}, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata])
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
const companyName = (() => {
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
const found = companies.find((c) => c.slug === machine.companySlug)
return found?.name ?? machine.companySlug
})()
const [renaming, setRenaming] = useState(false)
const [newName, setNewName] = useState<string>(machine?.hostname ?? "")
const [openDialog, setOpenDialog] = useState(false)
const [dialogQuery, setDialogQuery] = useState("")
const [deleteDialog, setDeleteDialog] = useState(false)
const [deleting, setDeleting] = useState(false)
const [accessDialog, setAccessDialog] = useState(false)
const [accessEmail, setAccessEmail] = useState<string>(collaborator?.email ?? "")
const [accessName, setAccessName] = useState<string>(collaborator?.name ?? "")
const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(
(machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator"
)
const [savingAccess, setSavingAccess] = useState(false)
const jsonText = useMemo(() => {
const payload = {
id: machine?.id,
hostname: machine?.hostname,
status: machine?.status,
lastHeartbeatAt: machine?.lastHeartbeatAt,
metrics,
inventory: metadata,
postureAlerts: machine?.postureAlerts ?? null,
lastPostureAt: machine?.lastPostureAt ?? null,
}
return JSON.stringify(payload, null, 2)
}, [machine, metrics, metadata])
const filteredJsonHtml = useMemo(() => {
if (!dialogQuery.trim()) return jsonText
const q = dialogQuery.trim().toLowerCase()
// highlight simples
return jsonText.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (m) => `__HIGHLIGHT__${m}__END__`)
}, [jsonText, dialogQuery])
// removed copy/export inventory JSON buttons as requested
useEffect(() => {
setAccessEmail(collaborator?.email ?? "")
setAccessName(collaborator?.name ?? "")
setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator")
}, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role])
const handleSaveAccess = async () => {
if (!machine) return
if (!accessEmail.trim()) {
toast.error("Informe o e-mail do colaborador ou gestor.")
return
}
setSavingAccess(true)
try {
const response = await fetch("/api/admin/machines/access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
machineId: machine.id,
persona: accessRole,
email: accessEmail.trim(),
name: accessName.trim() || undefined,
}),
})
if (!response.ok) {
throw new Error(await response.text())
}
toast.success("Perfil de acesso atualizado.")
setAccessDialog(false)
} catch (error) {
console.error(error)
toast.error("Falha ao atualizar acesso da máquina.")
} finally {
setSavingAccess(false)
}
}
return (
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Detalhes</CardTitle>
<CardDescription>Resumo da máquina selecionada.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{!machine ? (
<p className="text-sm text-muted-foreground">Selecione uma máquina para visualizar detalhes.</p>
) : (
<div className="space-y-6">
<section className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="break-words text-2xl font-semibold text-neutral-900">{machine.hostname}</h1>
<Button size="icon" variant="ghost" className="size-7" onClick={() => { setNewName(machine.hostname); setRenaming(true) }}>
<Pencil className="size-4" />
<span className="sr-only">Renomear máquina</span>
</Button>
</div>
<p className="text-xs text-muted-foreground">
{machine.authEmail ?? "E-mail não definido"}
</p>
{machine.companySlug ? (
<p className="text-xs text-muted-foreground">
Empresa vinculada: <span className="font-medium text-foreground">{companyName ?? machine.companySlug}</span>
</p>
) : null}
</div>
<MachineStatusBadge status={machine.status} />
</div>
{/* ping integrado na badge de status */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
<span className="mr-2 inline-flex items-center"><OsIcon osName={machine.osName} /></span>
{machine.osName ?? "SO desconhecido"} {machine.osVersion ?? ""}
</Badge>
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
{machine.architecture?.toUpperCase() ?? "Arquitetura indefinida"}
</Badge>
{windowsExt?.osInfo ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Build: {String(windowsExt.osInfo?.CurrentBuildNumber ?? windowsExt.osInfo?.CurrentBuild ?? "—")}
</Badge>
) : null}
{windowsExt?.osInfo ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Ativado: {windowsExt.osInfo?.IsActivated === true ? "Sim" : "Não"}
</Badge>
) : null}
{primaryGpu?.name ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
GPU: {primaryGpu.name}
{typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}
</Badge>
) : null}
{companyName ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Empresa: {companyName}
</Badge>
) : null}
{collaborator?.email ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
{personaLabel}: {collaborator?.name ? `${collaborator.name} · ` : ""}{collaborator.email}
</Badge>
) : null}
</div>
<div className="flex flex-wrap gap-2">
{machine.authEmail ? (
<Button size="sm" variant="outline" onClick={copyEmail} className="gap-2 border-dashed">
<ClipboardCopy className="size-4" />
Copiar e-mail
</Button>
) : null}
<Button size="sm" variant="outline" className="gap-2 border-dashed" onClick={() => { setAccessDialog(true) }}>
<ShieldCheck className="size-4" />
Ajustar acesso
</Button>
{machine.registeredBy ? (
<Badge variant="outline" className="h-7 border-slate-300 bg-slate-100 px-3 text-sm font-medium text-slate-700">
Registrada via {machine.registeredBy}
</Badge>
) : null}
</div>
</section>
{/* Renomear máquina */}
<Dialog open={renaming} onOpenChange={setRenaming}>
<DialogContent>
<DialogHeader>
<DialogTitle>Renomear máquina</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Novo hostname" />
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRenaming(false)}>Cancelar</Button>
<Button
onClick={async () => {
if (!machine) return
const name = (newName ?? "").trim()
if (name.length < 2) {
toast.error("Informe um nome válido")
return
}
try {
const res = await fetch("/api/admin/machines/rename", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id, hostname: name }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
toast.success("Máquina renomeada")
setRenaming(false)
} catch (err) {
console.error(err)
toast.error("Falha ao renomear máquina")
}
}}
>
Salvar
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={accessDialog} onOpenChange={setAccessDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ajustar acesso da máquina</DialogTitle>
</DialogHeader>
<div className="grid gap-3 py-2">
<div className="grid gap-2">
<label className="text-sm font-medium">Perfil</label>
<Select value={accessRole} onValueChange={(value) => setAccessRole((value as "collaborator" | "manager") ?? "collaborator")}>
<SelectTrigger>
<SelectValue placeholder="Selecione o perfil" />
</SelectTrigger>
<SelectContent>
<SelectItem value="collaborator">Colaborador (portal)</SelectItem>
<SelectItem value="manager">Gestor (painel completo)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">E-mail</label>
<Input type="email" value={accessEmail} onChange={(e) => setAccessEmail(e.target.value)} placeholder="colaborador@empresa.com" />
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Nome (opcional)</label>
<Input value={accessName} onChange={(e) => setAccessName(e.target.value)} placeholder="Nome completo" />
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setAccessDialog(false)} disabled={savingAccess}>Cancelar</Button>
<Button onClick={handleSaveAccess} disabled={savingAccess || !accessEmail.trim()}>
{savingAccess ? "Salvando..." : "Salvar"}
</Button>
</div>
</DialogContent>
</Dialog>
<section className="space-y-2">
<h4 className="text-sm font-semibold">Sincronização</h4>
<div className="grid gap-2 text-sm text-muted-foreground">
<div className="flex justify-between gap-4">
<span>Último heartbeat</span>
<span className="text-right font-medium text-foreground">
{formatRelativeTime(lastHeartbeatDate)}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Criada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(machine.createdAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Atualizada em</span>
<span className="text-right font-medium text-foreground">{formatDate(new Date(machine.updatedAt))}</span>
</div>
<div className="flex justify-between gap-4">
<span>Token expira</span>
<span className="text-right font-medium text-foreground">
{tokenExpiry ? formatRelativeTime(tokenExpiry) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Token usado por último</span>
<span className="text-right font-medium text-foreground">
{tokenLastUsed ? formatRelativeTime(tokenLastUsed) : "—"}
</span>
</div>
<div className="flex justify-between gap-4">
<span>Uso do token</span>
<span className="text-right font-medium text-foreground">{machine.token?.usageCount ?? 0} trocas</span>
</div>
</div>
</section>
{metrics && typeof metrics === "object" ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Métricas recentes</h4>
<MetricsGrid metrics={metrics} />
</section>
) : null}
{hardware || network || (labels && labels.length > 0) ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário</h4>
<p className="text-xs text-muted-foreground">
Dados sincronizados via agente ou Fleet.
</p>
</div>
<div className="space-y-3 text-sm text-muted-foreground">
{hardware ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Hardware</p>
<div className="mt-2 grid gap-1">
<DetailLine label="Fabricante" value={hardware.vendor} />
<DetailLine label="Modelo" value={hardware.model} />
<DetailLine label="Número de série" value={hardware.serial} />
<DetailLine label="CPU" value={hardware.cpuType} />
<DetailLine
label="Núcleos"
value={`${hardware.physicalCores ?? "?"} físicos / ${hardware.logicalCores ?? "?"} lógicos`}
/>
<DetailLine label="Memória" value={formatBytes(Number(hardware.memoryBytes ?? hardware.memory))} />
{displayGpus.length > 0 ? (
<div className="mt-2 space-y-1 text-xs text-muted-foreground">
<p className="font-semibold uppercase text-slate-500">GPUs</p>
<ul className="space-y-1">
{displayGpus.slice(0, 3).map((gpu, idx) => {
const { name, memoryBytes, driver, vendor } = gpu
return (
<li key={`gpu-${idx}`}>
<span className="font-medium text-foreground">{name ?? "Adaptador de vídeo"}</span>
{memoryBytes ? <span className="ml-1 text-muted-foreground">{formatBytes(memoryBytes)}</span> : null}
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
{driver ? <span className="ml-1 text-muted-foreground">· Driver {driver}</span> : null}
</li>
)
})}
{displayGpus.length > 3 ? (
<li className="text-muted-foreground">+{displayGpus.length - 3} adaptadores adicionais</li>
) : null}
</ul>
</div>
) : null}
</div>
</div>
) : null}
{networkInterfaces ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Rede (interfaces)</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Interface</TableHead>
<TableHead className="text-xs text-slate-500">MAC</TableHead>
<TableHead className="text-xs text-slate-500">IP</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{networkInterfaces.map((iface, idx) => (
<TableRow key={`iface-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{iface?.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.mac ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface?.ip ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : networkSummary ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Rede</p>
<div className="mt-2 grid gap-1">
<DetailLine label="IP primário" value={networkSummary.primaryIp} />
<DetailLine label="IP público" value={networkSummary.publicIp} />
<DetailLine
label="MAC addresses"
value={
Array.isArray(networkSummary.macAddresses)
? networkSummary.macAddresses.join(", ")
: machine?.macAddresses.join(", ")
}
/>
</div>
</div>
) : null}
{labels && labels.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Labels</p>
<div className="mt-2 flex flex-wrap gap-2">
{labels.slice(0, 12).map((label, index) => (
<Badge key={String(label.id ?? `${label.name ?? "label"}-${index}`)} variant="outline">
{label.name ?? `Label ${index + 1}`}
</Badge>
))}
{labels.length > 12 ? (
<Badge variant="outline">+{labels.length - 12} outras</Badge>
) : null}
</div>
</div>
) : null}
</div>
</section>
) : null}
{/* Discos (agente) */}
{disks.length > 0 ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Discos e partições</h4>
<div className="rounded-md border border-slate-200 bg-slate-50/60">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Nome</TableHead>
<TableHead className="text-xs text-slate-500">Mount</TableHead>
<TableHead className="text-xs text-slate-500">FS</TableHead>
<TableHead className="text-xs text-slate-500">Capacidade</TableHead>
<TableHead className="text-xs text-slate-500">Livre</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{disks.map((d, idx) => (
<TableRow key={`disk-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{d.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.mountPoint ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{d.fs ?? "—"}</TableCell>
<TableCell className="text-sm text-foreground">{formatBytes(Number(d.totalBytes))}</TableCell>
<TableCell className="text-sm text-muted-foreground">{formatBytes(Number(d.availableBytes))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
) : null}
{/* Inventário estendido por SO */}
{extended ? (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">Inventário estendido</h4>
<p className="text-xs text-muted-foreground">Dados ricos coletados pelo agente, variam por sistema operacional.</p>
</div>
{/* Linux */}
{linuxExt ? (
<div className="space-y-3">
{linuxLsblk.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Montagens (lsblk)</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Nome</TableHead>
<TableHead className="text-xs text-slate-500">Ponto de montagem</TableHead>
<TableHead className="text-xs text-slate-500">FS</TableHead>
<TableHead className="text-xs text-slate-500">Tamanho</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{linuxLsblk.slice(0, 18).map((entry, idx) => {
const name = entry.name ?? "—"
const mp = entry.mountPoint ?? entry.mountpoint ?? "—"
const fs = entry.fs ?? entry.fstype ?? "—"
const sizeRaw = typeof entry.sizeBytes === "number" ? entry.sizeBytes : entry.size
return (
<TableRow key={`lsblk-${idx}`} className="border-slate-100">
<TableCell className="text-sm text-foreground">{name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{mp || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{fs || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{typeof sizeRaw === "number" ? formatBytes(sizeRaw) : "—"}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{linuxSmartEntries.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-emerald-50/40 p-3 dark:bg-emerald-900/10">
<p className="text-xs font-semibold uppercase text-slate-500">SMART</p>
<div className="mt-2 grid gap-2">
{linuxSmartEntries.map((smartEntry, idx) => {
const ok = smartEntry.smart_status?.passed !== false
const model = smartEntry.model_name ?? smartEntry.model_family ?? "Disco"
const serial = smartEntry.serial_number ?? smartEntry.device?.name ?? "—"
return (
<div
key={`smart-${idx}`}
className={cn(
"flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm",
ok
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-700"
: "border-rose-500/20 bg-rose-500/15 text-rose-700"
)}
>
<span className="font-medium text-foreground">
{model} <span className="text-muted-foreground">({serial})</span>
</span>
<Badge variant="outline" className="text-xs uppercase">
{ok ? "OK" : "ALERTA"}
</Badge>
</div>
)
})}
</div>
</div>
) : null}
{linuxExt.lspci ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">PCI</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lspci}</pre>
</div>
) : null}
{linuxExt.lsusb ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">USB</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{linuxExt.lsusb}</pre>
</div>
) : null}
</div>
) : null}
{/* Windows */}
{windowsExt ? (
<div className="space-y-3">
{/* Cards resumidos: CPU / RAM / GPU / Discos */}
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<Cpu className="size-5 text-slate-500" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">CPU</p>
<p className="break-words text-sm font-semibold text-foreground">{winCpu?.Name ?? "—"}</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<MemoryStick className="size-5 text-slate-500" />
<div>
<p className="text-xs text-muted-foreground">Memória total</p>
<p className="text-sm font-semibold text-foreground">{formatBytes(winMemTotal)}</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<Monitor className="size-5 text-slate-500" />
<div className="min-w-0">
<p className="truncate text-xs text-muted-foreground">GPU</p>
<p className="truncate text-sm font-semibold text-foreground">{(windowsPrimaryGpu ?? primaryGpu)?.name ?? "—"}</p>
</div>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardContent className="flex items-center gap-3 py-3">
<HardDrive className="size-5 text-slate-500" />
<div>
<p className="text-xs text-muted-foreground">Discos</p>
<p className="text-sm font-semibold text-foreground">{winDiskStats.count} · {formatBytes(winDiskStats.total)}</p>
</div>
</CardContent>
</Card>
</div>
{windowsCpuDetails.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">CPU</p>
{windowsCpuDetails.slice(0, 1).map((cpuRecord, idx) => (
<div key={`cpu-${idx}`} className="mt-2 grid gap-1 text-sm text-muted-foreground">
<DetailLine label="Modelo" value={cpuRecord?.Name ?? "—"} classNameValue="break-words" />
<DetailLine label="Fabricante" value={cpuRecord?.Manufacturer ?? "—"} />
<DetailLine label="Socket" value={cpuRecord?.SocketDesignation ?? "—"} />
<DetailLine label="Núcleos" value={cpuRecord?.NumberOfCores != null ? `${cpuRecord.NumberOfCores}` : "—"} />
<DetailLine label="Threads" value={cpuRecord?.NumberOfLogicalProcessors != null ? `${cpuRecord.NumberOfLogicalProcessors}` : "—"} />
<DetailLine label="L2" value={cpuRecord?.L2CacheSize != null ? `${cpuRecord.L2CacheSize} KB` : "—"} />
<DetailLine label="L3" value={cpuRecord?.L3CacheSize != null ? `${cpuRecord.L3CacheSize} KB` : "—"} />
<DetailLine label="Clock máx" value={cpuRecord?.MaxClockSpeed != null ? `${cpuRecord.MaxClockSpeed} MHz` : "—"} />
</div>
))}
</div>
) : null}
{windowsExt.baseboard || windowsExt.bios ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Placa-mãe / BIOS</p>
<div className="mt-2 grid gap-1 text-sm text-muted-foreground">
{(() => {
const b = Array.isArray(windowsExt.baseboard) ? windowsExt.baseboard[0] : windowsExt.baseboard
const bios = Array.isArray(windowsExt.bios) ? windowsExt.bios[0] : windowsExt.bios
return (
<>
<DetailLine label="Board" value={b ? `${String(b?.["Manufacturer"] ?? "")} ${String(b?.["Product"] ?? "")}`.trim() : "—"} />
<DetailLine label="Board SN" value={b ? String(b?.["SerialNumber"] ?? "—") : "—"} />
<DetailLine label="BIOS" value={bios ? `${String(bios?.["Manufacturer"] ?? "")} ${String(bios?.["SMBIOSBIOSVersion"] ?? "")}`.trim() : "—"} />
</>
)
})()}
</div>
</div>
) : null}
{windowsServices.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Serviços</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Nome</TableHead>
<TableHead className="text-xs text-slate-500">Exibição</TableHead>
<TableHead className="text-xs text-slate-500">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsServices.slice(0, 10).map((service, index) => {
const record = toRecord(service) ?? {}
const name = readString(record, "Name", "name") ?? "—"
const displayName = readString(record, "DisplayName", "displayName") ?? "—"
const status = readString(record, "Status", "status") ?? "—"
return (
<TableRow key={`svc-${index}`} className="border-slate-100">
<TableCell className="text-sm">{name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{displayName}</TableCell>
<TableCell className="text-sm text-muted-foreground">{status}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{windowsSoftware.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Softwares (amostra)</p>
<ul className="mt-2 grid gap-1 text-xs text-muted-foreground">
{windowsSoftware.slice(0, 8).map((softwareItem, index) => {
const record = toRecord(softwareItem) ?? {}
const name = readString(record, "DisplayName", "name") ?? "—"
const version = readString(record, "DisplayVersion", "version")
const publisher = readString(record, "Publisher")
return (
<li key={`sw-${index}`}>
<span className="font-medium text-foreground">{name}</span>
{version ? <span className="ml-1">{version}</span> : null}
{publisher ? <span className="ml-1">· {publisher}</span> : null}
</li>
)
})}
</ul>
</div>
) : null}
{windowsMemoryModules.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Módulos de memória</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Banco</TableHead>
<TableHead className="text-xs text-slate-500">Capacidade</TableHead>
<TableHead className="text-xs text-slate-500">Fabricante</TableHead>
<TableHead className="text-xs text-slate-500">PartNumber</TableHead>
<TableHead className="text-xs text-slate-500">Clock</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsMemoryModules.map((module, idx) => {
const record = toRecord(module) ?? {}
const bank = readString(record, "BankLabel", "bankLabel") ?? "—"
const capacityBytes =
parseBytesLike(record["Capacity"]) ?? parseBytesLike(record["capacity"]) ?? 0
const manufacturer = readString(record, "Manufacturer", "manufacturer") ?? "—"
const partNumber = readString(record, "PartNumber", "partNumber") ?? "—"
const clockValue = readNumber(
record,
"ConfiguredClockSpeed",
"configuredClockSpeed",
"Speed",
"speed"
)
const clockLabel =
typeof clockValue === "number" && Number.isFinite(clockValue)
? `${clockValue} MHz`
: "—"
return (
<TableRow key={`mem-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{bank}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{capacityBytes > 0 ? formatBytes(capacityBytes) : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{manufacturer}</TableCell>
<TableCell className="text-sm text-muted-foreground">{partNumber}</TableCell>
<TableCell className="text-sm text-muted-foreground">{clockLabel}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{windowsVideoControllers.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Adaptadores de vídeo</p>
<ul className="mt-2 grid gap-1 text-xs text-muted-foreground">
{windowsVideoControllers.map((controller, idx) => {
const record = toRecord(controller) ?? {}
const normalized = normalizeGpuSource(record)
const name =
normalized?.name ??
readString(record, "Name", "name") ??
"—"
const ram =
normalized?.memoryBytes ??
parseBytesLike(record["AdapterRAM"]) ??
undefined
const driver = normalized?.driver ?? readString(record, "DriverVersion", "driverVersion")
const vendor = normalized?.vendor ?? readString(record, "AdapterCompatibility")
return (
<li key={`vid-${idx}`}>
<span className="font-medium text-foreground">{name}</span>
{typeof ram === "number" && ram > 0 ? <span className="ml-1">{formatBytes(ram)}</span> : null}
{vendor ? <span className="ml-1 text-muted-foreground">· {vendor}</span> : null}
{driver ? <span className="ml-1">· Driver {driver}</span> : null}
</li>
)
})}
</ul>
</div>
) : null}
{windowsDiskEntries.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Discos físicos</p>
<div className="mt-2 overflow-hidden rounded-md border border-slate-200">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Modelo</TableHead>
<TableHead className="text-xs text-slate-500">Tamanho</TableHead>
<TableHead className="text-xs text-slate-500">Interface</TableHead>
<TableHead className="text-xs text-slate-500">Tipo</TableHead>
<TableHead className="text-xs text-slate-500">Serial</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{windowsDiskEntries.map((disk, idx) => {
const record = toRecord(disk) ?? {}
const model = readString(record, "Model", "model")
const serial = readString(record, "SerialNumber", "serialNumber")
const size = parseBytesLike(record["Size"])
const iface = readString(record, "InterfaceType", "interfaceType")
const media = readString(record, "MediaType", "mediaType")
return (
<TableRow key={`diskp-${idx}`} className="border-slate-100">
<TableCell className="text-sm">{model ?? serial ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{typeof size === "number" && size > 0 ? formatBytes(size) : "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{iface ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{media ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{serial ?? "—"}</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : null}
{windowsExt.defender ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Defender</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
{readBool(windowsExt.defender, "AntivirusEnabled") === true ? (
<Badge className="gap-1 border-emerald-500/20 bg-emerald-500/15 text-emerald-700">
<ShieldCheck className="size-3" /> Antivírus: Ativo
</Badge>
) : (
<Badge className="gap-1 border-rose-500/20 bg-rose-500/15 text-rose-700">
<ShieldAlert className="size-3" /> Antivírus: Inativo
</Badge>
)}
{readBool(windowsExt.defender, "RealTimeProtectionEnabled") === true ? (
<Badge className="gap-1 border-emerald-500/20 bg-emerald-500/15 text-emerald-700">
<ShieldCheck className="size-3" /> Proteção em tempo real: Ativa
</Badge>
) : (
<Badge className="gap-1 border-rose-500/20 bg-rose-500/15 text-rose-700">
<ShieldAlert className="size-3" /> Proteção em tempo real: Inativa
</Badge>
)}
</div>
</div>
) : null}
</div>
) : null}
{/* macOS */}
{macosExt ? (
<div className="space-y-3">
{Array.isArray(macosExt.packages) && macosExt.packages.length > 0 ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Pacotes</p>
<p className="mt-1 text-xs text-muted-foreground">{macosExt.packages.slice(0, 8).join(", ")}</p>
</div>
) : null}
{macosExt.launchctl ? (
<div className="rounded-md border border-slate-200 bg-slate-50/60 p-3">
<p className="text-xs font-semibold uppercase text-slate-500">Launchctl</p>
<pre className="mt-2 whitespace-pre-wrap break-words text-xs text-muted-foreground">{macosExt.launchctl}</pre>
</div>
) : null}
</div>
) : null}
</section>
) : null}
{/* Postura/Alertas */}
{Array.isArray(machine?.postureAlerts) && machine?.postureAlerts?.length ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Alertas de postura</h4>
<div className="space-y-2">
{machine?.postureAlerts?.map((a: { kind?: string; message?: string; severity?: string }, i: number) => (
<div key={`alert-${i}`} className={cn("flex items-center justify-between rounded-md border px-3 py-2 text-sm",
(a?.severity ?? "warning").toLowerCase() === "critical" ? "border-rose-500/20 bg-rose-500/10" : "border-amber-500/20 bg-amber-500/10")
}>
<span className="font-medium text-foreground">{a?.message ?? a?.kind ?? "Alerta"}</span>
<Badge variant="outline">{String(a?.kind ?? "ALERTA")}</Badge>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">
Última avaliação: {machine?.lastPostureAt ? formatRelativeTime(new Date(machine.lastPostureAt)) : "—"}
</p>
</section>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
{Array.isArray(software) && software.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(software, "softwares.csv")}>Softwares CSV</Button>
) : null}
{Array.isArray(metadata?.services) && metadata.services.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(metadata.services as Array<Record<string, unknown>>, "servicos.csv")}>Serviços CSV</Button>
) : null}
</div>
{fleet ? (
<section className="space-y-2 text-sm text-muted-foreground">
<Separator />
<div className="flex items-center justify-between">
<span>ID Fleet</span>
<span className="font-medium text-foreground">{fleet.id ?? "—"}</span>
</div>
<div className="flex items-center justify-between">
<span>Team ID</span>
<span className="font-medium text-foreground">{fleet.teamId ?? "—"}</span>
</div>
<div className="flex items-center justify-between">
<span>Detalhes atualizados</span>
<span className="font-medium text-foreground">
{fleet.detailUpdatedAt ? formatDate(new Date(String(fleet.detailUpdatedAt))) : "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Versão osquery</span>
<span className="font-medium text-foreground">{fleet.osqueryVersion ?? "—"}</span>
</div>
</section>
) : null}
{software && software.length > 0 ? (
<section className="space-y-2">
<h4 className="text-sm font-semibold">Softwares detectados</h4>
<div className="rounded-md border border-slate-200 bg-slate-50/60">
<Table>
<TableHeader>
<TableRow className="border-slate-200 bg-slate-100/80">
<TableHead className="text-xs text-slate-500">Nome</TableHead>
<TableHead className="text-xs text-slate-500">Versão</TableHead>
<TableHead className="text-xs text-slate-500">Fonte</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{software.slice(0, 6).map((item, index) => (
<TableRow key={`${item.name ?? "software"}-${index}`} className="border-slate-100">
<TableCell className="text-sm text-foreground">{item.name ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item.version ?? "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{item.source ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{software.length > 6 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">
+{software.length - 6} softwares adicionais sincronizados via Fleet.
</p>
) : null}
</div>
</section>
) : null}
{machine ? (
<section className="space-y-2 rounded-md border border-rose-200 bg-rose-50/60 p-3">
<div className="space-y-1">
<h4 className="text-sm font-semibold text-rose-700">Zona perigosa</h4>
<p className="text-xs text-rose-600">
Excluir a máquina revoga o token atual e remove os dados de inventário sincronizados.
</p>
</div>
<Button variant="destructive" size="sm" onClick={() => setDeleteDialog(true)}>Excluir máquina</Button>
</section>
) : null}
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<div className="flex justify-end">
<DialogTrigger asChild>
<Button size="sm" variant="outline" onClick={() => setOpenDialog(true)}>Inventário completo</Button>
</DialogTrigger>
</div>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Inventário completo {machine.hostname}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Input placeholder="Buscar no JSON" value={dialogQuery} onChange={(e) => setDialogQuery(e.target.value)} />
<div className="max-h-[60vh] overflow-auto rounded-md border border-slate-200 bg-slate-50/60 p-3 text-xs">
<pre className="whitespace-pre-wrap break-words text-muted-foreground" dangerouslySetInnerHTML={{ __html: filteredJsonHtml
.replaceAll("__HIGHLIGHT__", '<mark class="bg-yellow-200 text-foreground">')
.replaceAll("__END__", '</mark>')
}} />
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteDialog} onOpenChange={(open) => { if (!open) setDeleting(false); setDeleteDialog(open) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Excluir máquina</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>Tem certeza que deseja excluir <span className="font-semibold text-foreground">{machine?.hostname}</span>? Esta ação não pode ser desfeita.</p>
<p>Os tokens ativos serão revogados e o inventário deixará de aparecer no painel.</p>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteDialog(false)} disabled={deleting}>Cancelar</Button>
<Button
variant="destructive"
disabled={deleting}
onClick={async () => {
if (!machine) return
setDeleting(true)
try {
const res = await fetch("/api/admin/machines/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: machine.id }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
toast.success("Máquina excluída")
setDeleteDialog(false)
router.push("/admin/machines")
} catch (err) {
console.error(err)
toast.error("Falha ao excluir máquina")
} finally {
setDeleting(false)
}
}}
>
{deleting ? "Excluindo..." : "Excluir máquina"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)}
</CardContent>
</Card>
)
}
function MachinesGrid({ machines, companyNameBySlug }: { machines: MachinesQueryItem[]; companyNameBySlug: Map<string, string> }) {
if (!machines || machines.length === 0) return <EmptyState />
return (
<div className="grid grid-cols-1 gap-3 [@supports(display:grid)]:grid-cols-[repeat(auto-fit,minmax(280px,1fr))]">
{machines.map((m) => (
<MachineCard
key={m.id}
machine={m}
companyName={m.companySlug ? companyNameBySlug.get(m.companySlug) ?? m.companySlug : null}
/>
))}
</div>
)
}
function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; companyName?: string | null }) {
const { className } = getStatusVariant(machine.status)
const lastHeartbeat = machine.lastHeartbeatAt ? new Date(machine.lastHeartbeatAt) : null
type AgentMetrics = {
memoryUsedBytes?: number
memoryTotalBytes?: number
memoryUsedPercent?: number
cpuUsagePercent?: number
}
const mm = (machine.metrics ?? null) as unknown as AgentMetrics | null
const memUsed = mm?.memoryUsedBytes ?? NaN
const memTotal = mm?.memoryTotalBytes ?? NaN
const memPct = mm?.memoryUsedPercent ?? (Number.isFinite(memUsed) && Number.isFinite(memTotal) ? (Number(memUsed) / Number(memTotal)) * 100 : NaN)
const cpuPct = mm?.cpuUsagePercent ?? NaN
const collaborator = (() => {
if (machine.assignedUserEmail) {
return {
email: machine.assignedUserEmail ?? undefined,
name: machine.assignedUserName ?? undefined,
role: machine.persona ?? machine.assignedUserRole ?? undefined,
}
}
const inv = machine.inventory as unknown
if (!inv || typeof inv !== "object") return null
const raw = (inv as Record<string, unknown>).collaborator
if (!raw || typeof raw !== "object") return null
const obj = raw as Record<string, unknown>
const email = typeof obj.email === "string" ? obj.email : undefined
if (!email) return null
return {
email,
name: typeof obj.name === "string" ? obj.name : undefined,
role: typeof obj.role === "string" ? (obj.role as string) : undefined,
}
})()
const persona = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
const companyLabel = companyName ?? machine.companySlug ?? null
return (
<Link href={`/admin/machines/${machine.id}`} className="group">
<Card className="relative h-full overflow-hidden border-slate-200 transition-colors hover:border-slate-300">
<div className="absolute right-2 top-2">
<span
aria-hidden
className={cn(
"relative block size-2 rounded-full",
className.includes("emerald")
? "bg-emerald-500"
: className.includes("rose")
? "bg-rose-500"
: className.includes("amber")
? "bg-amber-500"
: "bg-slate-400"
)}
/>
{String(machine.status ?? "").toLowerCase() === "online" ? (
<span className="absolute left-1/2 top-1/2 -z-10 size-4 -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-400/30 animate-ping" />
) : null}
</div>
<CardHeader className="pb-2">
<CardTitle className="line-clamp-1 flex items-center gap-2 text-base font-semibold">
<Monitor className="size-4 text-slate-500" /> {machine.hostname}
</CardTitle>
<CardDescription className="line-clamp-1 text-xs">{machine.authEmail ?? "—"}</CardDescription>
</CardHeader>
<CardContent className="flex grow flex-col gap-3 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
{machine.osName ?? "SO"} {machine.osVersion ?? ""}
</Badge>
{machine.architecture ? (
<Badge variant="outline" className="border-slate-300 bg-slate-100 text-xs font-medium text-slate-700">
{machine.architecture.toUpperCase()}
</Badge>
) : null}
{companyLabel ? (
<Badge variant="outline" className="text-xs">{companyLabel}</Badge>
) : null}
</div>
{collaborator?.email ? (
<p className="text-[11px] text-muted-foreground">
{persona}: {collaborator.name ? `${collaborator.name} · ` : ""}
{collaborator.email}
</p>
) : null}
<div className="grid grid-cols-2 gap-2">
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5">
<Cpu className="size-4 text-slate-500" />
<span className="text-xs font-medium text-slate-800">{formatPercent(cpuPct)}</span>
</div>
<div className="flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50/80 px-2 py-1.5">
<MemoryStick className="size-4 text-slate-500" />
<span className="text-xs font-medium text-slate-800">
{Number.isFinite(memUsed) && Number.isFinite(memTotal)
? `${formatBytes(memUsed)} / ${formatBytes(memTotal)}`
: formatPercent(memPct)}
</span>
</div>
</div>
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<HardDrive className="size-3.5 text-slate-500" />
{Array.isArray(machine.inventory?.disks) ? `${machine.inventory?.disks?.length ?? 0} discos` : "—"}
</span>
<span>
{lastHeartbeat ? formatRelativeTime(lastHeartbeat) : "sem heartbeat"}
</span>
</div>
</CardContent>
</Card>
</Link>
)
}
function DetailLine({ label, value, classNameValue }: DetailLineProps) {
if (value === null || value === undefined) return null
if (typeof value === "string" && (value.trim() === "" || value === "undefined" || value === "null")) {
return null
}
return (
<div className="flex items-center justify-between gap-4">
<span>{label}</span>
<span className={cn("text-right font-medium text-foreground", classNameValue)}>{value}</span>
</div>
)
}
function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
const data = (metrics ?? {}) as Record<string, unknown>
// Compat: aceitar chaves do agente desktop (cpuUsagePercent, memoryUsedBytes, memoryTotalBytes)
const cpu = (() => {
const v = Number(
data.cpuUsage ?? data.cpu ?? data.cpu_percent ?? data.cpuUsagePercent ?? NaN
)
return v
})()
const memory = (() => {
// valor absoluto em bytes, se disponível
const memBytes = Number(
data.memoryBytes ?? data.memory ?? data.memory_used ?? data.memoryUsedBytes ?? NaN
)
if (Number.isFinite(memBytes)) return memBytes
// tentar derivar a partir de percentuais do agente
const usedPct = Number(data.memoryUsedPercent ?? NaN)
const totalBytes = Number(data.memoryTotalBytes ?? NaN)
if (Number.isFinite(usedPct) && Number.isFinite(totalBytes)) {
return Math.max(0, Math.min(1, usedPct > 1 ? usedPct / 100 : usedPct)) * totalBytes
}
return NaN
})()
const disk = Number(data.diskUsage ?? data.disk ?? NaN)
const gpuUsage = Number(
data.gpuUsage ?? data.gpu ?? data.gpuUsagePercent ?? data.gpu_percent ?? NaN
)
const cards: Array<{ label: string; value: string }> = [
{ label: "CPU", value: formatPercent(cpu) },
{ label: "Memória", value: formatBytes(memory) },
{ label: "Disco", value: Number.isNaN(disk) ? "—" : formatPercent(disk) },
]
if (!Number.isNaN(gpuUsage)) {
cards.push({ label: "GPU", value: formatPercent(gpuUsage) })
}
return (
<div className="grid gap-2 sm:grid-cols-3 md:grid-cols-4">
{cards.map((card) => (
<div key={card.label} className="rounded-md border border-slate-200 bg-slate-50/60 p-3 text-sm text-muted-foreground">
<p className="text-xs uppercase text-slate-500">{card.label}</p>
<p className="text-sm font-semibold text-foreground">{card.value}</p>
</div>
))}
</div>
)
}
function exportCsv(items: Array<Record<string, unknown>>, filename: string) {
if (!Array.isArray(items) || items.length === 0) return
const headersSet = new Set<string>()
items.forEach((it) => Object.keys(it ?? {}).forEach((k) => headersSet.add(k)))
const headers = Array.from(headersSet)
const csv = [headers.join(",")]
for (const it of items) {
const row = headers
.map((h) => {
const v = (it as Record<string, unknown>)[h]
if (v === undefined || v === null) return ""
const s = typeof v === "string" ? v : JSON.stringify(v)
return `"${s.replace(/"/g, '""')}"`
})
.join(",")
csv.push(row)
}
const blob = new Blob([csv.join("\n")], { type: "text/csv;charset=utf-8;" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}