feat(admin/ui): filters + badges + full inventory dialog with search; CSV export; types tightened; feat(desktop): charts in diagnostics and heartbeat interval settings; feat(agent): normalized software/services; linux lspci/lsusb parsed

This commit is contained in:
Esdras Renan 2025-10-09 22:29:59 -03:00
parent e682c6773a
commit 0556502685
4 changed files with 308 additions and 46 deletions

View file

@ -240,6 +240,60 @@ fn build_inventory_metadata(system: &System) -> serde_json::Value {
} }
} }
// Normalização de software/serviços no topo do inventário
if let Some(obj) = inventory.as_object_mut() {
// Merge software
let mut software: Vec<serde_json::Value> = Vec::new();
if let Some(existing) = obj.get("software").and_then(|v| v.as_array()) {
software.extend(existing.iter().cloned());
}
if let Some(ext) = obj.get("extended").and_then(|v| v.as_object()) {
// Windows normalize
if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) {
if let Some(ws) = win.get("software").and_then(|v| v.as_array()) {
for item in ws {
let name = item.get("DisplayName").or_else(|| item.get("name")).cloned().unwrap_or(json!(null));
let version = item.get("DisplayVersion").or_else(|| item.get("version")).cloned().unwrap_or(json!(null));
let publisher = item.get("Publisher").cloned().unwrap_or(json!(null));
software.push(json!({ "name": name, "version": version, "source": publisher }));
}
}
}
// macOS normalize
if let Some(macos) = ext.get("macos").and_then(|v| v.as_object()) {
if let Some(pkgs) = macos.get("packages").and_then(|v| v.as_array()) {
for p in pkgs {
software.push(json!({ "name": p, "version": null, "source": "pkgutil" }));
}
}
}
}
if !software.is_empty() {
obj.insert("software".into(), json!(software));
}
// Merge services
let mut services: Vec<serde_json::Value> = Vec::new();
if let Some(existing) = obj.get("services").and_then(|v| v.as_array()) {
services.extend(existing.iter().cloned());
}
if let Some(ext) = obj.get("extended").and_then(|v| v.as_object()) {
if let Some(win) = ext.get("windows").and_then(|v| v.as_object()) {
if let Some(wsvc) = win.get("services").and_then(|v| v.as_array()) {
for s in wsvc {
let name = s.get("Name").cloned().unwrap_or(json!(null));
let status = s.get("Status").cloned().unwrap_or(json!(null));
let display = s.get("DisplayName").cloned().unwrap_or(json!(null));
services.push(json!({ "name": name, "status": status, "displayName": display }));
}
}
}
}
if !services.is_empty() {
obj.insert("services".into(), json!(services));
}
}
json!({ "inventory": inventory }) json!({ "inventory": inventory })
} }
@ -355,6 +409,17 @@ fn collect_linux_extended() -> serde_json::Value {
.ok() .ok()
.map(|out| String::from_utf8_lossy(&out.stdout).to_string()) .map(|out| String::from_utf8_lossy(&out.stdout).to_string())
.unwrap_or_default(); .unwrap_or_default();
// Parse básico de lspci/lsusb em listas
fn parse_lines_to_list(input: &str) -> Vec<serde_json::Value> {
input
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.map(|l| json!({ "text": l }))
.collect::<Vec<_>>()
}
let pci_list = parse_lines_to_list(&lspci);
let usb_list = parse_lines_to_list(&lsusb);
// smartctl (se disponível) por disco // smartctl (se disponível) por disco
let mut smart: Vec<serde_json::Value> = Vec::new(); let mut smart: Vec<serde_json::Value> = Vec::new();
@ -393,6 +458,8 @@ fn collect_linux_extended() -> serde_json::Value {
"lsblk": block_json, "lsblk": block_json,
"lspci": lspci, "lspci": lspci,
"lsusb": lsusb, "lsusb": lsusb,
"pciList": pci_list,
"usbList": usb_list,
"smart": smart, "smart": smart,
} }
}) })

View file

@ -56,6 +56,7 @@ type AgentConfig = {
createdAt: number createdAt: number
lastSyncedAt?: number | null lastSyncedAt?: number | null
expiresAt?: number | null expiresAt?: number | null
heartbeatIntervalSec?: number | null
} }
declare global { declare global {
@ -186,7 +187,7 @@ async function startHeartbeat(config: AgentConfig) {
baseUrl: config.apiBaseUrl, baseUrl: config.apiBaseUrl,
token, token,
status: "online", status: "online",
intervalSeconds: 300, intervalSeconds: Math.max(60, Number(config.heartbeatIntervalSec ?? 300)),
}) })
} }
@ -319,7 +320,59 @@ function renderDiagnosticsPanel(profile: MachineProfile) {
.map((v) => (v ? v.toFixed(2) : "—")) .map((v) => (v ? v.toFixed(2) : "—"))
.join(" / ")}</div> .join(" / ")}</div>
</div> </div>
<div class="machine-summary">
<div><strong>Histórico (curto)</strong></div>
<canvas id="diag-cpu" width="520" height="100" style="background:rgba(148,163,184,0.15); border-radius:8px;"></canvas>
<canvas id="diag-mem" width="520" height="100" style="background:rgba(148,163,184,0.15); border-radius:8px;"></canvas>
<p class="text-xs">Amostras locais a cada ~3s, mantemos ~60 pontos.</p>
</div>
` `
const cpuCanvas = panel.querySelector<HTMLCanvasElement>("#diag-cpu")
const memCanvas = panel.querySelector<HTMLCanvasElement>("#diag-mem")
const cpuData: number[] = []
const memData: number[] = []
function draw(canvas: HTMLCanvasElement, series: number[], maxValue: number, color: string) {
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
const w = canvas.width
const h = canvas.height
const n = Math.max(1, series.length)
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.beginPath()
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * (w - 6) + 3
const v = Math.max(0, Math.min(maxValue, series[i] ?? 0))
const y = h - 4 - (v / maxValue) * (h - 8)
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
let stop = false
async function pump() {
if (stop) return
try {
const p = await collectMachineProfile()
cpuData.push(Math.max(0, Math.min(100, p.metrics.cpuUsagePercent)))
const memPct = (p.metrics.memoryUsedPercent)
memData.push(Math.max(0, Math.min(100, memPct)))
while (cpuData.length > 60) cpuData.shift()
while (memData.length > 60) memData.shift()
if (cpuCanvas) draw(cpuCanvas, cpuData, 100, "#2563eb")
if (memCanvas) draw(memCanvas, memData, 100, "#10b981")
} catch {
// ignore
} finally {
setTimeout(pump, 3000)
}
}
pump()
panel.addEventListener("DOMNodeRemoved", () => { stop = true })
} }
function renderSettingsPanel(config: AgentConfig) { function renderSettingsPanel(config: AgentConfig) {
@ -339,6 +392,14 @@ function renderSettingsPanel(config: AgentConfig) {
<div class="actions"> <div class="actions">
<button id="reset-agent-settings" class="secondary">Reprovisionar</button> <button id="reset-agent-settings" class="secondary">Reprovisionar</button>
</div> </div>
<div class="machine-summary">
<div><strong>Intervalo do heartbeat (segundos)</strong></div>
<div style="display:flex; gap:8px; align-items:center;">
<input id="hb-interval" type="number" min="60" step="30" value="${String(config.heartbeatIntervalSec ?? 300)}" style="max-width:140px;" />
<button id="save-hb-interval">Salvar</button>
</div>
<p class="text-xs">Mínimo 60s. Salvar reinicia o processo de heartbeat.</p>
</div>
` `
document.getElementById("open-app-settings")?.addEventListener("click", () => redirectToApp(config)) document.getElementById("open-app-settings")?.addEventListener("click", () => redirectToApp(config))
@ -374,6 +435,21 @@ function renderSettingsPanel(config: AgentConfig) {
setAlert("Falha ao enviar inventário agora.", "error") setAlert("Falha ao enviar inventário agora.", "error")
} }
}) })
document.getElementById("save-hb-interval")?.addEventListener("click", async () => {
try {
const input = document.getElementById("hb-interval") as HTMLInputElement | null
const value = Math.max(60, Number(input?.value ?? 300))
const updated: AgentConfig = { ...config, heartbeatIntervalSec: value, lastSyncedAt: Date.now() }
await saveConfig(updated)
await stopHeartbeat().catch(() => undefined)
await startHeartbeat(updated)
setAlert("Intervalo do heartbeat atualizado.", "success")
} catch (error) {
console.error("[agent] Falha ao salvar intervalo", error)
setAlert("Falha ao salvar intervalo do heartbeat.", "error")
}
})
} }
function formatBytes(bytes: number) { function formatBytes(bytes: number) {

View file

@ -175,11 +175,22 @@ async function evaluatePostureAndMaybeRaise(
) { ) {
const findings: PostureFinding[] = [] const findings: PostureFinding[] = []
// Janela temporal de CPU (5 minutos)
const now = Date.now()
const metrics = args.metrics ?? (args.metadata?.metrics ?? null) const metrics = args.metrics ?? (args.metadata?.metrics ?? null)
if (metrics && typeof metrics === "object") { const metaObj = machine.metadata && typeof machine.metadata === "object" ? (machine.metadata as Record<string, unknown>) : {}
const usage = Number((metrics as any).cpuUsagePercent ?? (metrics as any).cpu_usage_percent) const prevWindow: Array<{ ts: number; usage: number }> = Array.isArray((metaObj as any).cpuWindow)
if (Number.isFinite(usage) && usage >= 90) { ? (((metaObj as any).cpuWindow as Array<any>).map((p) => ({ ts: Number(p.ts ?? 0), usage: Number(p.usage ?? NaN) })).filter((p) => Number.isFinite(p.ts) && Number.isFinite(p.usage)))
findings.push({ kind: "CPU_HIGH", message: `CPU acima de ${usage.toFixed(0)}%`, severity: "warning" }) : []
const window = prevWindow.filter((p) => now - p.ts <= 5 * 60 * 1000)
const usage = Number((metrics as any)?.cpuUsagePercent ?? (metrics as any)?.cpu_usage_percent ?? NaN)
if (Number.isFinite(usage)) {
window.push({ ts: now, usage })
}
if (window.length > 0) {
const avg = window.reduce((acc, p) => acc + p.usage, 0) / window.length
if (avg >= 90) {
findings.push({ kind: "CPU_HIGH", message: `CPU média ${avg.toFixed(0)}% em 5 min`, severity: "warning" })
} }
} }
@ -187,23 +198,37 @@ async function evaluatePostureAndMaybeRaise(
if (inventory && typeof inventory === "object") { if (inventory && typeof inventory === "object") {
const services = (inventory as any).services const services = (inventory as any).services
if (Array.isArray(services)) { if (Array.isArray(services)) {
const criticalDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? "").toLowerCase() !== "running") const criticalList = (process.env["MACHINE_CRITICAL_SERVICES"] ?? "")
if (criticalDown) { .split(/[\s,]+/)
findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${criticalDown.name}`, severity: "warning" }) .map((s) => s.trim().toLowerCase())
.filter(Boolean)
const criticalSet = new Set(criticalList)
const firstDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? s?.Status ?? "").toLowerCase() !== "running")
if (firstDown) {
const name = String(firstDown.name ?? firstDown.Name ?? "serviço")
const sev: "warning" | "critical" = criticalSet.has(name.toLowerCase()) ? "critical" : "warning"
findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${name}`, severity: sev })
} }
} }
const smart = (inventory as any).extended?.linux?.smart const smart = (inventory as any).extended?.linux?.smart
if (Array.isArray(smart)) { if (Array.isArray(smart)) {
const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false) const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false)
if (failing) { if (failing) {
findings.push({ kind: "SMART_FAIL", message: `Disco com SMART em falha`, severity: "critical" }) const model = failing?.model_name ?? failing?.model_family ?? "Disco"
const serial = failing?.serial_number ?? failing?.device?.name ?? "—"
const temp = failing?.temperature?.current ?? failing?.temperature?.value ?? null
const details = temp ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})`
findings.push({ kind: "SMART_FAIL", message: `SMART em falha: ${details}`, severity: "critical" })
} }
} }
} }
// Persistir janela de CPU (limite de 120 amostras)
const cpuWindowCapped = window.slice(-120)
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, { cpuWindow: cpuWindowCapped }) })
if (!findings.length) return if (!findings.length) return
const now = Date.now()
const record = { const record = {
postureAlerts: findings, postureAlerts: findings,
lastPostureAt: now, lastPostureAt: now,
@ -213,7 +238,6 @@ async function evaluatePostureAndMaybeRaise(
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now }) await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now })
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return
// Evita excesso: não cria ticket se já houve alerta nos últimos 30 minutos
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
const subject = `Alerta de máquina: ${machine.hostname}` const subject = `Alerta de máquina: ${machine.hostname}`

View file

@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { import {
Table, Table,
TableBody, TableBody,
@ -38,6 +39,28 @@ type MachineSoftware = {
source?: string source?: string
} }
type LinuxExtended = {
lsblk?: unknown
lspci?: string
lsusb?: string
pciList?: Array<{ text: string }>
usbList?: Array<{ text: string }>
smart?: Array<Record<string, unknown>>
}
type WindowsExtended = {
software?: Array<Record<string, unknown>>
services?: Array<Record<string, unknown>>
defender?: Record<string, unknown>
hotfix?: Array<Record<string, unknown>>
}
type MacExtended = {
systemProfiler?: Record<string, unknown>
packages?: string[]
launchctl?: string
}
type MachineInventory = { type MachineInventory = {
hardware?: { hardware?: {
vendor?: string vendor?: string
@ -49,11 +72,7 @@ type MachineInventory = {
memoryBytes?: number memoryBytes?: number
memory?: number memory?: number
} }
network?: { network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | Array<{ name?: string; mac?: string; ip?: string }>
primaryIp?: string
publicIp?: string
macAddresses?: string[]
}
software?: MachineSoftware[] software?: MachineSoftware[]
labels?: MachineLabel[] labels?: MachineLabel[]
fleet?: { fleet?: {
@ -64,26 +83,7 @@ type MachineInventory = {
} }
// Dados enviados pelo agente desktop (inventário básico/estendido) // Dados enviados pelo agente desktop (inventário básico/estendido)
disks?: Array<{ name?: string; mountPoint?: string; fs?: string; totalBytes?: number; availableBytes?: number }> disks?: Array<{ name?: string; mountPoint?: string; fs?: string; totalBytes?: number; availableBytes?: number }>
network?: any // pode ser objeto (Fleet) ou array de interfaces (agente desktop) extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended }
extended?: {
linux?: {
lsblk?: any
lspci?: string
lsusb?: string
smart?: any[]
}
windows?: {
software?: any
services?: any
defender?: any
hotfix?: any
}
macos?: {
systemProfiler?: any
packages?: string[]
launchctl?: string
}
}
} }
type MachinesQueryItem = { type MachinesQueryItem = {
@ -296,15 +296,16 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
) : ( ) : (
<div className="overflow-x-auto max-h-[70vh] overflow-y-auto rounded-md border border-slate-200"> <div className="overflow-x-auto max-h-[70vh] overflow-y-auto rounded-md border border-slate-200">
<Table className=""> <Table className="">
<TableHeader className="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <TableHeader className="sticky top-0 z-10 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<TableRow className="border-slate-200"> <TableRow className="border-slate-200">
<TableHead>Hostname</TableHead> <TableHead>Hostname</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Último heartbeat</TableHead> <TableHead>Último heartbeat</TableHead>
<TableHead>Empresa</TableHead> <TableHead>Empresa</TableHead>
<TableHead>Plataforma</TableHead> <TableHead>Plataforma</TableHead>
</TableRow> <TableHead>Resumo</TableHead>
</TableHeader> </TableRow>
</TableHeader>
<TableBody> <TableBody>
{filteredMachines.map((machine: MachinesQueryItem) => ( {filteredMachines.map((machine: MachinesQueryItem) => (
<TableRow <TableRow
@ -341,6 +342,21 @@ export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
{machine.architecture ? machine.architecture.toUpperCase() : "—"} {machine.architecture ? machine.architecture.toUpperCase() : "—"}
</p> </p>
</TableCell> </TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
{Array.isArray(machine.postureAlerts) && machine.postureAlerts.length > 0 ? (
<Badge className="border-rose-500/20 bg-rose-500/15 text-rose-700">{machine.postureAlerts.length} alertas</Badge>
) : (
<Badge variant="outline" className="text-slate-600">0 alertas</Badge>
)}
{Array.isArray(machine.inventory?.disks) ? (
<Badge variant="outline">{machine.inventory?.disks?.length ?? 0} discos</Badge>
) : null}
{Array.isArray((machine.inventory as any)?.services) ? (
<Badge variant="outline">{(machine.inventory as any).services.length} serviços</Badge>
) : null}
</div>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -387,7 +403,7 @@ function MachineDetails({ machine }: MachineDetailsProps) {
const labels = metadata?.labels ?? null const labels = metadata?.labels ?? null
const fleet = metadata?.fleet ?? null const fleet = metadata?.fleet ?? null
const disks = Array.isArray(metadata?.disks) ? metadata?.disks ?? [] : [] const disks = Array.isArray(metadata?.disks) ? metadata?.disks ?? [] : []
const extended = (metadata as any)?.extended ?? null const extended = metadata?.extended ?? null
const linuxExt = extended?.linux ?? null const linuxExt = extended?.linux ?? null
const windowsExt = extended?.windows ?? null const windowsExt = extended?.windows ?? null
const macosExt = extended?.macos ?? null const macosExt = extended?.macos ?? null
@ -406,6 +422,29 @@ function MachineDetails({ machine }: MachineDetailsProps) {
} }
} }
const [openDialog, setOpenDialog] = useState(false)
const [dialogQuery, setDialogQuery] = useState("")
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])
const exportInventoryJson = () => { const exportInventoryJson = () => {
if (!machine) return if (!machine) return
const payload = { const payload = {
@ -802,6 +841,12 @@ function MachineDetails({ machine }: MachineDetailsProps) {
<div className="flex flex-wrap gap-2 pt-2"> <div className="flex flex-wrap gap-2 pt-2">
<Button size="sm" variant="outline" onClick={copyInventoryJson}>Copiar JSON</Button> <Button size="sm" variant="outline" onClick={copyInventoryJson}>Copiar JSON</Button>
<Button size="sm" onClick={exportInventoryJson}>Exportar JSON</Button> <Button size="sm" onClick={exportInventoryJson}>Exportar JSON</Button>
{Array.isArray(software) && software.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv(software, "softwares.csv")}>Softwares CSV</Button>
) : null}
{Array.isArray((metadata as any)?.services) && (metadata as any).services.length > 0 ? (
<Button size="sm" variant="outline" onClick={() => exportCsv((metadata as any).services, "servicos.csv")}>Serviços CSV</Button>
) : null}
</div> </div>
{fleet ? ( {fleet ? (
<section className="space-y-2 text-sm text-muted-foreground"> <section className="space-y-2 text-sm text-muted-foreground">
@ -857,6 +902,28 @@ function MachineDetails({ machine }: MachineDetailsProps) {
</div> </div>
</section> </section>
) : null} ) : 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>
</div> </div>
)} )}
</CardContent> </CardContent>
@ -902,3 +969,31 @@ function MetricsGrid({ metrics }: { metrics: MachineMetrics }) {
</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 any)?.[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)
}