feat: improve machines inventory exports

This commit is contained in:
codex-bot 2025-10-30 16:09:06 -03:00
parent d92c817e7b
commit 38b46f32ce
5 changed files with 858 additions and 222 deletions

View file

@ -0,0 +1,69 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export"
export const runtime = "nodejs"
type RouteContext = {
params: Promise<{
id: string
}>
}
function sanitizeFilename(hostname: string, fallback: string): string {
const safe = hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase()
return safe || fallback
}
export async function GET(_request: Request, context: RouteContext) {
const session = await assertAuthenticatedSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const convexUrl = env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) {
return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
}
const { id } = await context.params
const machineId = id as Id<"machines">
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
try {
const machine = (await client.query(api.machines.getById, {
id: machineId,
includeMetadata: true,
})) as MachineInventoryRecord | null
if (!machine || machine.tenantId !== tenantId) {
return NextResponse.json({ error: "Máquina não encontrada" }, { status: 404 })
}
const workbook = buildMachinesInventoryWorkbook([machine], {
tenantId,
generatedBy: session.user.name ?? session.user.email,
companyFilterLabel: machine.companyName ?? machine.companySlug ?? null,
generatedAt: new Date(),
})
const hostnameSafe = sanitizeFilename(machine.hostname, "machine")
const body = new Uint8Array(workbook)
return new NextResponse(body, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="machine-inventory-${hostnameSafe}.xlsx"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to export machine inventory", error)
return NextResponse.json({ error: "Falha ao gerar planilha da máquina" }, { status: 500 })
}
}

View file

@ -2,96 +2,13 @@ import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser" import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env" import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server" import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants" import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { buildXlsxWorkbook } from "@/lib/xlsx" import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export"
export const runtime = "nodejs" export const runtime = "nodejs"
type MachineListEntry = {
id: Id<"machines">
tenantId: string
hostname: string
companyId: Id<"companies"> | null
companySlug: string | null
companyName: string | null
status: string | null
isActive: boolean
lastHeartbeatAt: number | null
persona: string | null
assignedUserName: string | null
assignedUserEmail: string | null
authEmail: string | null
osName: string
osVersion: string | null
architecture: string | null
macAddresses: string[]
serialNumbers: string[]
registeredBy: string | null
createdAt: number
updatedAt: number
token: { expiresAt: number; usageCount: number; lastUsedAt: number | null } | null
inventory: Record<string, unknown> | null
linkedUsers?: Array<{ id: string; email: string; name: string }>
}
function formatIso(value: number | null | undefined): string | null {
if (typeof value !== "number") return null
try {
return new Date(value).toISOString()
} catch {
return null
}
}
function formatMemory(bytes: unknown): number | null {
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return null
const gib = bytes / (1024 ** 3)
return Number(gib.toFixed(2))
}
function extractPrimaryIp(inventory: Record<string, unknown> | null): string | null {
if (!inventory) return null
const network = inventory.network
if (!network) return null
if (Array.isArray(network)) {
for (const entry of network) {
if (entry && typeof entry === "object") {
const candidate = (entry as { ip?: unknown }).ip
if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim()
}
}
} else if (typeof network === "object") {
const record = network as Record<string, unknown>
const ip =
typeof record.primaryIp === "string"
? record.primaryIp
: typeof record.publicIp === "string"
? record.publicIp
: null
if (ip && ip.trim().length > 0) return ip.trim()
}
return null
}
function extractHardware(inventory: Record<string, unknown> | null) {
if (!inventory) return {}
const hardware = inventory.hardware
if (!hardware || typeof hardware !== "object") return {}
const hw = hardware as Record<string, unknown>
return {
vendor: typeof hw.vendor === "string" ? hw.vendor : null,
model: typeof hw.model === "string" ? hw.model : null,
serial: typeof hw.serial === "string" ? hw.serial : null,
cpuType: typeof hw.cpuType === "string" ? hw.cpuType : null,
physicalCores: typeof hw.physicalCores === "number" ? hw.physicalCores : null,
logicalCores: typeof hw.logicalCores === "number" ? hw.logicalCores : null,
memoryBytes: typeof hw.memoryBytes === "number" ? hw.memoryBytes : null,
}
}
export async function GET(request: Request) { export async function GET(request: Request) {
const session = await assertAuthenticatedSession() const session = await assertAuthenticatedSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
@ -130,126 +47,28 @@ export async function GET(request: Request) {
const machines = (await client.query(api.machines.listByTenant, { const machines = (await client.query(api.machines.listByTenant, {
tenantId, tenantId,
includeMetadata: true, includeMetadata: true,
})) as MachineListEntry[] })) as MachineInventoryRecord[]
const filtered = machines.filter((machine) => { const filtered = machines.filter((machine) => {
if (!companyId) return true if (!companyId) return true
return String(machine.companyId ?? "") === companyId || machine.companySlug === companyId return String(machine.companyId ?? "") === companyId || machine.companySlug === companyId
}) })
const companyFilterLabel = (() => {
if (!companyId) return null
const matchById = filtered.find((machine) => machine.companyId && String(machine.companyId) === companyId)
if (matchById?.companyName) return matchById.companyName
const matchBySlug = filtered.find((machine) => machine.companySlug === companyId)
if (matchBySlug?.companyName) return matchBySlug.companyName
return companyId
})()
const statusCounts = filtered.reduce<Record<string, number>>((acc, machine) => { const workbook = buildMachinesInventoryWorkbook(filtered, {
const key = machine.status ?? "unknown" tenantId,
acc[key] = (acc[key] ?? 0) + 1 generatedBy: session.user.name ?? session.user.email,
return acc companyFilterLabel,
}, {}) generatedAt: new Date(),
const summaryRows: Array<Array<unknown>> = [
["Tenant", tenantId],
["Total de máquinas", filtered.length],
]
if (companyId) summaryRows.push(["Filtro de empresa", companyId])
Object.entries(statusCounts).forEach(([status, total]) => {
summaryRows.push([`Status: ${status}`, total])
}) })
const inventorySheetRows = filtered.map((machine) => {
const inventory =
machine.inventory && typeof machine.inventory === "object"
? (machine.inventory as Record<string, unknown>)
: null
const hardware = extractHardware(inventory)
const primaryIp = extractPrimaryIp(inventory)
const memoryGiB = formatMemory(hardware.memoryBytes)
return [
machine.hostname,
machine.companyName ?? "—",
machine.status ?? "unknown",
machine.isActive ? "Sim" : "Não",
formatIso(machine.lastHeartbeatAt),
machine.persona ?? null,
machine.assignedUserName ?? null,
machine.assignedUserEmail ?? null,
machine.authEmail ?? null,
machine.osName,
machine.osVersion ?? null,
machine.architecture ?? null,
machine.macAddresses.join(", "),
machine.serialNumbers.join(", "),
machine.registeredBy ?? null,
formatIso(machine.createdAt),
formatIso(machine.updatedAt),
hardware.vendor,
hardware.model,
hardware.serial,
hardware.cpuType,
hardware.physicalCores,
hardware.logicalCores,
memoryGiB,
primaryIp,
machine.token?.expiresAt ? formatIso(machine.token.expiresAt) : null,
machine.token?.usageCount ?? null,
]
})
const linksSheetRows: Array<Array<unknown>> = []
filtered.forEach((machine) => {
if (!machine.linkedUsers || machine.linkedUsers.length === 0) return
machine.linkedUsers.forEach((user) => {
linksSheetRows.push([
machine.hostname,
machine.companyName ?? "—",
user.name ?? user.email ?? "—",
user.email ?? "—",
])
})
})
const workbook = buildXlsxWorkbook([
{
name: "Resumo",
headers: ["Item", "Valor"],
rows: summaryRows,
},
{
name: "Máquinas",
headers: [
"Hostname",
"Empresa",
"Status",
"Ativa",
"Último heartbeat",
"Persona",
"Responsável",
"E-mail responsável",
"E-mail autenticado",
"Sistema operacional",
"Versão SO",
"Arquitetura",
"Endereços MAC",
"Seriais",
"Registrada via",
"Criada em",
"Atualizada em",
"Fabricante",
"Modelo",
"Serial hardware",
"Processador",
"Cores físicas",
"Cores lógicas",
"Memória (GiB)",
"IP principal",
"Token expira em",
"Uso do token",
],
rows: inventorySheetRows.length > 0 ? inventorySheetRows : [["—"]],
},
{
name: "Vínculos",
headers: ["Hostname", "Empresa", "Usuário", "E-mail"],
rows: linksSheetRows.length > 0 ? linksSheetRows : [["—", "—", "—", "—"]],
},
])
const body = new Uint8Array(workbook) const body = new Uint8Array(workbook)
return new NextResponse(body, { return new NextResponse(body, {

View file

@ -2004,7 +2004,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
} }
return JSON.stringify(payload, null, 2) return JSON.stringify(payload, null, 2)
}, [machine, metrics, metadata]) }, [machine, metrics, metadata])
const handleDownloadInventory = useCallback(() => { const handleDownloadInventoryJson = useCallback(() => {
if (!machine) return if (!machine) return
const safeHostname = machine.hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase() const safeHostname = machine.hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase()
const fileName = `${safeHostname || "machine"}_${machine.id}.json` const fileName = `${safeHostname || "machine"}_${machine.id}.json`
@ -3915,7 +3915,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
) : null} ) : null}
<Dialog open={openDialog} onOpenChange={setOpenDialog}> <Dialog open={openDialog} onOpenChange={setOpenDialog}>
<div className="flex justify-end"> <div className="flex flex-wrap items-center justify-end gap-2">
{machine ? (
<Button size="sm" variant="outline" asChild className="inline-flex items-center gap-2">
<a href={`/api/admin/machines/${machine.id}/inventory.xlsx`} download>
<Download className="size-4" /> Exportar planilha
</a>
</Button>
) : null}
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm" variant="outline" onClick={() => setOpenDialog(true)}>Inventário completo</Button> <Button size="sm" variant="outline" onClick={() => setOpenDialog(true)}>Inventário completo</Button>
</DialogTrigger> </DialogTrigger>
@ -3932,15 +3939,24 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
onChange={(e) => setDialogQuery(e.target.value)} onChange={(e) => setDialogQuery(e.target.value)}
className="sm:flex-1" className="sm:flex-1"
/> />
<Button <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
type="button" <Button
variant="outline" type="button"
size="sm" variant="outline"
onClick={handleDownloadInventory} size="sm"
className="inline-flex items-center gap-2" onClick={handleDownloadInventoryJson}
> className="inline-flex items-center gap-2"
<Download className="size-4" /> Baixar JSON >
</Button> <Download className="size-4" /> Baixar JSON
</Button>
{machine ? (
<Button type="button" variant="outline" size="sm" asChild className="inline-flex items-center gap-2">
<a href={`/api/admin/machines/${machine.id}/inventory.xlsx`} download>
<Download className="size-4" /> Baixar planilha
</a>
</Button>
) : null}
</div>
</div> </div>
<div className="max-h-[60vh] overflow-auto rounded-md border border-slate-200 bg-slate-50/60 p-3 text-xs"> <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 <pre className="whitespace-pre-wrap break-words text-muted-foreground" dangerouslySetInnerHTML={{ __html: filteredJsonHtml

View file

@ -6,6 +6,12 @@ export type WorksheetConfig = {
name: string name: string
headers: string[] headers: string[]
rows: WorksheetRow[] rows: WorksheetRow[]
columnWidths?: Array<number | null | undefined>
freezePane?: {
rowSplit?: number
columnSplit?: number
}
autoFilter?: boolean
} }
type ZipEntry = { type ZipEntry = {
@ -38,22 +44,23 @@ function columnRef(index: number): string {
return col return col
} }
function formatCell(value: unknown, colIndex: number, rowIndex: number): string { function formatCell(value: unknown, colIndex: number, rowNumber: number, styleIndex?: number): string {
const ref = `${columnRef(colIndex)}${rowIndex + 1}` const ref = `${columnRef(colIndex)}${rowNumber}`
const styleAttr = styleIndex !== undefined ? ` s="${styleIndex}"` : ""
if (value === null || value === undefined || value === "") { if (value === null || value === undefined || value === "") {
return `<c r="${ref}"/>` return `<c r="${ref}"${styleAttr}/>`
} }
if (value instanceof Date) { if (value instanceof Date) {
return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(value.toISOString())}</t></is></c>` return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(value.toISOString())}</t></is></c>`
} }
if (typeof value === "number" && Number.isFinite(value)) { if (typeof value === "number" && Number.isFinite(value)) {
return `<c r="${ref}"><v>${value}</v></c>` return `<c r="${ref}"${styleAttr}><v>${value}</v></c>`
} }
if (typeof value === "boolean") { if (typeof value === "boolean") {
return `<c r="${ref}"><v>${value ? 1 : 0}</v></c>` return `<c r="${ref}"${styleAttr}><v>${value ? 1 : 0}</v></c>`
} }
let text: string let text: string
@ -62,25 +69,75 @@ function formatCell(value: unknown, colIndex: number, rowIndex: number): string
} else { } else {
text = JSON.stringify(value) text = JSON.stringify(value)
} }
return `<c r="${ref}" t="inlineStr"><is><t>${escapeXml(text)}</t></is></c>` return `<c r="${ref}"${styleAttr} t="inlineStr"><is><t xml:space="preserve">${escapeXml(text)}</t></is></c>`
} }
function buildWorksheetXml(config: WorksheetConfig): string { type WorksheetStyles = {
header: number
body: number
}
function buildWorksheetXml(config: WorksheetConfig, styles: WorksheetStyles): string {
const totalRows = config.rows.length + 1
const rows: string[] = [] const rows: string[] = []
const headerRow = config.headers.map((header, idx) => formatCell(header, idx, 0)).join("") const headerRow = config.headers.map((header, idx) => formatCell(header, idx, 1, styles.header)).join("")
rows.push(`<row r="1">${headerRow}</row>`) rows.push(`<row r="1">${headerRow}</row>`)
config.rows.forEach((rowData, rowIdx) => { config.rows.forEach((rowData, rowIdx) => {
const cells = config.headers.map((_, colIdx) => formatCell(rowData[colIdx], colIdx, rowIdx + 1)).join("") const actualRow = rowIdx + 2
rows.push(`<row r="${rowIdx + 2}">${cells}</row>`) const cells = config.headers
.map((_, colIdx) => formatCell(rowData[colIdx], colIdx, actualRow, styles.body))
.join("")
rows.push(`<row r="${actualRow}">${cells}</row>`)
}) })
const hasCustomWidths = Array.isArray(config.columnWidths) && config.columnWidths.some((width) => typeof width === "number")
const colsXml = hasCustomWidths
? `<cols>${config.headers
.map((_, idx) => {
const width = config.columnWidths?.[idx]
if (typeof width !== "number" || Number.isNaN(width)) {
return `<col min="${idx + 1}" max="${idx + 1}"/>`
}
return `<col min="${idx + 1}" max="${idx + 1}" width="${width}" customWidth="1"/>`
})
.join("")}</cols>`
: ""
let sheetViews = ""
const rowSplit = config.freezePane?.rowSplit ?? 0
const columnSplit = config.freezePane?.columnSplit ?? 0
if (rowSplit > 0 || columnSplit > 0) {
const attributes: string[] = []
if (columnSplit > 0) attributes.push(`xSplit="${columnSplit}"`)
if (rowSplit > 0) attributes.push(`ySplit="${rowSplit}"`)
const topLeftColumn = columnSplit > 0 ? columnRef(columnSplit) : "A"
const topLeftRow = rowSplit > 0 ? rowSplit + 1 : 1
const activePane =
rowSplit > 0 && columnSplit > 0
? "bottomRight"
: rowSplit > 0
? "bottomLeft"
: "topRight"
const pane = `<pane ${attributes.join(" ")} topLeftCell="${topLeftColumn}${topLeftRow}" activePane="${activePane}" state="frozen"/>`
sheetViews = `<sheetViews><sheetView workbookViewId="0">${pane}</sheetView></sheetViews>`
}
const autoFilter =
config.autoFilter && config.headers.length > 0 && totalRows > 1
? `<autoFilter ref="A1:${columnRef(config.headers.length - 1)}${totalRows}"/>`
: ""
return [ return [
XML_DECLARATION, XML_DECLARATION,
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">', '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
sheetViews,
colsXml,
' <sheetFormatPr defaultRowHeight="15"/>',
" <sheetData>", " <sheetData>",
rows.map((row) => ` ${row}`).join("\n"), rows.map((row) => ` ${row}`).join("\n"),
" </sheetData>", " </sheetData>",
autoFilter,
"</worksheet>", "</worksheet>",
].join("\n") ].join("\n")
} }
@ -206,6 +263,10 @@ export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer {
throw new Error("Workbook requires at least one sheet") throw new Error("Workbook requires at least one sheet")
} }
const styles: WorksheetStyles = {
body: 0,
header: 1,
}
const now = new Date() const now = new Date()
const timestamp = now.toISOString() const timestamp = now.toISOString()
const workbookRels: string[] = [] const workbookRels: string[] = []
@ -214,7 +275,7 @@ export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer {
const sheetRefs = sheets.map((sheet, index) => { const sheetRefs = sheets.map((sheet, index) => {
const sheetId = index + 1 const sheetId = index + 1
const relId = `rId${sheetId}` const relId = `rId${sheetId}`
const worksheetXml = buildWorksheetXml(sheet) const worksheetXml = buildWorksheetXml(sheet, styles)
sheetEntries.push({ sheetEntries.push({
path: `xl/worksheets/sheet${sheetId}.xml`, path: `xl/worksheets/sheet${sheetId}.xml`,
data: Buffer.from(worksheetXml, "utf8"), data: Buffer.from(worksheetXml, "utf8"),
@ -248,11 +309,21 @@ export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer {
const stylesXml = [ const stylesXml = [
XML_DECLARATION, XML_DECLARATION,
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">', '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">',
' <fonts count="1"><font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font></fonts>', ' <fonts count="2">',
' <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>', ' <font><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font>',
' <font><b/><sz val="11"/><color theme="1"/><name val="Calibri"/><family val="2"/></font>',
" </fonts>",
' <fills count="3">',
' <fill><patternFill patternType="none"/></fill>',
' <fill><patternFill patternType="gray125"/></fill>',
' <fill><patternFill patternType="solid"><fgColor rgb="FFE2E8F0"/><bgColor indexed="64"/></patternFill></fill>',
" </fills>",
' <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>', ' <borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>',
' <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>', ' <cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>',
' <cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs>', ' <cellXfs count="2">',
' <xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>',
' <xf numFmtId="0" fontId="1" fillId="2" borderId="0" xfId="0" applyFont="1" applyFill="1"/>',
" </cellXfs>",
' <cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>', ' <cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>',
"</styleSheet>", "</styleSheet>",
].join("\n") ].join("\n")

View file

@ -0,0 +1,661 @@
import { buildXlsxWorkbook, type WorksheetConfig } from "@/lib/xlsx"
import type { Id } from "@/convex/_generated/dataModel"
type LinkedUser = {
id: string
email: string | null
name: string | null
}
export type MachineInventoryRecord = {
id: Id<"machines">
tenantId: string
hostname: string
companyId: Id<"companies"> | null
companySlug: string | null
companyName: string | null
status: string | null
isActive: boolean
lastHeartbeatAt: number | null
persona: string | null
assignedUserName: string | null
assignedUserEmail: string | null
authEmail: string | null
osName: string
osVersion: string | null
architecture: string | null
macAddresses: string[]
serialNumbers: string[]
registeredBy: string | null
createdAt: number
updatedAt: number
token: { expiresAt: number; usageCount: number; lastUsedAt: number | null } | null
inventory: Record<string, unknown> | null
linkedUsers?: LinkedUser[]
}
type WorkbookOptions = {
tenantId: string
generatedBy?: string | null
companyFilterLabel?: string | null
generatedAt?: Date
}
type SoftwareEntry = {
hostname: string
name: string
version: string | null
source: string | null
publisher: string | null
installedOn: string | null
}
type DiskEntry = {
hostname: string
type: string | null
model: string | null
name: string | null
mountPoint: string | null
size: string | null
free: string | null
serial: string | null
smartStatus: string | null
}
const INVENTORY_HEADERS = [
"Hostname",
"Empresa",
"Status",
"Persona",
"Ativa",
"Último heartbeat",
"Responsável",
"E-mail responsável",
"Usuários vinculados",
"E-mail autenticado",
"Sistema operacional",
"Versão SO",
"Arquitetura",
"Fabricante",
"Modelo",
"Serial hardware",
"Processador",
"Cores físicas",
"Cores lógicas",
"Memória (GiB)",
"GPUs",
"Labels",
"MACs",
"Seriais",
"IP principal",
"IP público",
"Registrada via",
"Token expira em",
"Token último uso",
"Uso do token",
"Criada em",
"Atualizada em",
"Softwares instalados",
] as const
const INVENTORY_COLUMN_WIDTHS = [
22, 26, 16, 14, 10, 20, 22, 24, 28, 24, 20, 18, 14, 18, 22, 22, 24, 12, 12, 14, 26, 20, 24, 24, 18, 18, 18, 20, 20, 14, 20, 20, 18,
] as const
const SOFTWARE_HEADERS = ["Hostname", "Aplicativo", "Versão", "Origem", "Publicador", "Instalado em"] as const
const SOFTWARE_COLUMN_WIDTHS = [22, 36, 18, 18, 22, 20] as const
const DISK_HEADERS = ["Hostname", "Tipo", "Modelo", "Nome", "Montagem", "Capacidade", "Livre", "Serial", "Status SMART"] as const
const DISK_COLUMN_WIDTHS = [22, 14, 24, 18, 18, 16, 16, 22, 18] as const
const STATUS_LABELS: Record<string, string> = {
online: "Online",
offline: "Offline",
stale: "Sem sinal",
maintenance: "Manutenção",
blocked: "Bloqueada",
deactivated: "Desativada",
unknown: "Desconhecido",
}
const PERSONA_LABELS: Record<string, string> = {
collaborator: "Colaborador",
manager: "Gestor",
machine: "Máquina",
}
const SUMMARY_STATUS_ORDER = ["Online", "Sem sinal", "Offline", "Manutenção", "Bloqueada", "Desativada", "Desconhecido"]
type WorksheetRow = Array<unknown>
export function buildMachinesInventoryWorkbook(
machines: MachineInventoryRecord[],
options: WorkbookOptions,
): Buffer {
const generatedAt = options.generatedAt ?? new Date()
const summaryRows = buildSummaryRows(machines, options, generatedAt)
const inventoryRows = machines.map((machine) => flattenMachine(machine))
const linksRows = buildLinkedUsersRows(machines)
const softwareRows = buildSoftwareRows(machines)
const diskRows = buildDiskRows(machines)
const sheets: WorksheetConfig[] = [
{
name: "Resumo",
headers: ["Item", "Valor"],
rows: summaryRows,
columnWidths: [28, 48],
},
{
name: "Inventário",
headers: [...INVENTORY_HEADERS],
rows: inventoryRows,
columnWidths: [...INVENTORY_COLUMN_WIDTHS],
freezePane: { rowSplit: 1 },
autoFilter: true,
},
{
name: "Vínculos",
headers: ["Hostname", "Empresa", "Usuário", "E-mail"],
rows: linksRows.length > 0 ? linksRows : [["—", "—", "—", "—"]],
columnWidths: [22, 26, 26, 28],
freezePane: { rowSplit: 1 },
autoFilter: true,
},
{
name: "Softwares",
headers: [...SOFTWARE_HEADERS],
rows: softwareRows.length > 0 ? softwareRows : [["—", "—", "—", "—", "—", "—"]],
columnWidths: [...SOFTWARE_COLUMN_WIDTHS],
freezePane: { rowSplit: 1 },
autoFilter: softwareRows.length > 0,
},
{
name: "Discos",
headers: [...DISK_HEADERS],
rows: diskRows.length > 0 ? diskRows : [Array(DISK_HEADERS.length).fill("—")],
columnWidths: [...DISK_COLUMN_WIDTHS],
freezePane: { rowSplit: 1 },
autoFilter: diskRows.length > 0,
},
]
return buildXlsxWorkbook(sheets)
}
function buildSummaryRows(
machines: MachineInventoryRecord[],
options: WorkbookOptions,
generatedAt: Date,
): Array<[string, unknown]> {
const rows: Array<[string, unknown]> = [
["Tenant", options.tenantId],
["Gerado em", formatDateTime(generatedAt.getTime()) ?? generatedAt.toISOString()],
]
if (options.generatedBy) {
rows.push(["Solicitado por", options.generatedBy])
}
if (options.companyFilterLabel) {
rows.push(["Filtro de empresa", options.companyFilterLabel])
}
rows.push(["Total de máquinas", machines.length])
const activeCount = machines.filter((machine) => machine.isActive).length
rows.push(["Máquinas ativas", activeCount])
rows.push(["Máquinas inativas", machines.length - activeCount])
const statusCounts = new Map<string, number>()
machines.forEach((machine) => {
const label = describeStatus(machine.status)
statusCounts.set(label, (statusCounts.get(label) ?? 0) + 1)
})
const sortedStatuses = Array.from(statusCounts.entries()).sort((a, b) => {
const indexA = SUMMARY_STATUS_ORDER.indexOf(a[0])
const indexB = SUMMARY_STATUS_ORDER.indexOf(b[0])
if (indexA === -1 && indexB === -1) return a[0].localeCompare(b[0], "pt-BR")
if (indexA === -1) return 1
if (indexB === -1) return -1
return indexA - indexB
})
sortedStatuses.forEach(([status, total]) => {
rows.push([`Status: ${status}`, total])
})
const uniqueCompanies = new Set(machines.map((machine) => machine.companyName).filter(Boolean) as string[])
if (uniqueCompanies.size > 0) {
rows.push(["Empresas no resultado", Array.from(uniqueCompanies).sort((a, b) => a.localeCompare(b, "pt-BR")).join(", ")])
}
return rows
}
function flattenMachine(machine: MachineInventoryRecord): WorksheetRow {
const inventory = toRecord(machine.inventory)
const hardware = extractHardware(inventory)
const gpuNames = extractGpuNames(inventory)
const labels = extractLabels(inventory)
const primaryIp = extractPrimaryIp(inventory)
const publicIp = extractPublicIp(inventory)
const softwareCount = countSoftwareEntries(inventory)
const linkedUsers = summarizeLinkedUsers(machine.linkedUsers)
return [
machine.hostname,
machine.companyName ?? "—",
describeStatus(machine.status),
describePersona(machine.persona),
yesNo(machine.isActive),
formatDateTime(machine.lastHeartbeatAt),
machine.assignedUserName ?? machine.assignedUserEmail ?? "—",
machine.assignedUserEmail ?? "—",
linkedUsers ?? "—",
machine.authEmail ?? "—",
machine.osName,
machine.osVersion ?? "—",
machine.architecture ?? "—",
hardware.vendor ?? "—",
hardware.model ?? "—",
hardware.serial ?? "—",
hardware.cpuType ?? "—",
hardware.physicalCores ?? "—",
hardware.logicalCores ?? "—",
hardware.memoryGiB ?? "—",
gpuNames.length > 0 ? gpuNames.join(", ") : "—",
labels.length > 0 ? labels.join(", ") : "—",
machine.macAddresses.length > 0 ? machine.macAddresses.join(", ") : "—",
machine.serialNumbers.length > 0 ? machine.serialNumbers.join(", ") : "—",
primaryIp ?? "—",
publicIp ?? "—",
machine.registeredBy ?? "—",
machine.token?.expiresAt ? formatDateTime(machine.token.expiresAt) ?? "—" : "—",
machine.token?.lastUsedAt ? formatDateTime(machine.token.lastUsedAt) ?? "—" : "—",
machine.token?.usageCount ?? 0,
formatDateTime(machine.createdAt) ?? "—",
formatDateTime(machine.updatedAt) ?? "—",
softwareCount ?? 0,
]
}
function buildLinkedUsersRows(machines: MachineInventoryRecord[]): Array<[string, string | null, string | null, string | null]> {
const rows: Array<[string, string | null, string | null, string | null]> = []
machines.forEach((machine) => {
machine.linkedUsers?.forEach((user) => {
rows.push([machine.hostname, machine.companyName ?? null, user.name ?? user.email ?? null, user.email ?? null])
})
})
return rows
}
function buildSoftwareRows(machines: MachineInventoryRecord[]): WorksheetRow[] {
const rows: WorksheetRow[] = []
machines.forEach((machine) => {
const inventory = toRecord(machine.inventory)
const entries = extractSoftwareEntries(machine.hostname, inventory)
entries.forEach((entry) => {
rows.push([
entry.hostname,
entry.name,
entry.version ?? "—",
entry.source ?? "—",
entry.publisher ?? "—",
entry.installedOn ?? "—",
])
})
})
return rows
}
function buildDiskRows(machines: MachineInventoryRecord[]): WorksheetRow[] {
const rows: WorksheetRow[] = []
machines.forEach((machine) => {
const inventory = toRecord(machine.inventory)
const entries = extractDiskEntries(machine.hostname, inventory)
entries.forEach((disk) => {
rows.push([
disk.hostname,
disk.type ?? "—",
disk.model ?? "—",
disk.name ?? "—",
disk.mountPoint ?? "—",
disk.size ?? "—",
disk.free ?? "—",
disk.serial ?? "—",
disk.smartStatus ?? "—",
])
})
})
return rows
}
function toRecord(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return null
}
function toRecordArray(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) {
return value.map(toRecord).filter((item): item is Record<string, unknown> => Boolean(item))
}
const record = toRecord(value)
return record ? [record] : []
}
function ensureString(value: unknown): string | null {
if (typeof value === "string") {
const trimmed = value.trim()
return trimmed.length ? trimmed : null
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value)
}
return null
}
function ensureNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
if (typeof value === "string") {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function pickValue(record: Record<string, unknown> | null | undefined, keys: string[]): unknown {
if (!record) return undefined
for (const key of keys) {
if (key in record) {
const value = record[key]
if (value !== undefined && value !== null && value !== "") {
return value
}
}
}
return undefined
}
function pickString(record: Record<string, unknown> | null | undefined, keys: string[]): string | null {
return ensureString(pickValue(record, keys))
}
function pickNumber(record: Record<string, unknown> | null | undefined, keys: string[]): number | null {
return ensureNumber(pickValue(record, keys))
}
function pickRecord(record: Record<string, unknown> | null | undefined, keys: string[]): Record<string, unknown> | null {
return toRecord(pickValue(record, keys))
}
function pickRecordArray(record: Record<string, unknown> | null | undefined, keys: string[]): Record<string, unknown>[] {
return toRecordArray(pickValue(record, keys))
}
function pickArray(record: Record<string, unknown> | null | undefined, keys: string[]): unknown[] {
const value = pickValue(record, keys)
if (Array.isArray(value)) return value
if (value === undefined || value === null) return []
return [value]
}
function describeStatus(status: string | null | undefined): string {
if (!status) return STATUS_LABELS.unknown
const normalized = status.toLowerCase()
return STATUS_LABELS[normalized] ?? status
}
function describePersona(persona: string | null | undefined): string {
if (!persona) return "—"
const normalized = persona.toLowerCase()
return PERSONA_LABELS[normalized] ?? persona
}
function yesNo(value: boolean | null | undefined): string {
if (value === undefined || value === null) return "—"
return value ? "Sim" : "Não"
}
function formatDateTime(value: number | null | undefined): string | null {
if (typeof value !== "number" || Number.isNaN(value)) return null
const date = new Date(value)
const yyyy = date.getUTCFullYear()
const mm = `${date.getUTCMonth() + 1}`.padStart(2, "0")
const dd = `${date.getUTCDate()}`.padStart(2, "0")
const hh = `${date.getUTCHours()}`.padStart(2, "0")
const min = `${date.getUTCMinutes()}`.padStart(2, "0")
return `${yyyy}-${mm}-${dd} ${hh}:${min} UTC`
}
function formatBytes(value: number | null | undefined): string | null {
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) return null
const gib = value / 1024 ** 3
return `${gib.toFixed(2)} GiB`
}
function extractHardware(inventory: Record<string, unknown> | null) {
if (!inventory) {
return {
vendor: null,
model: null,
serial: null,
cpuType: null,
physicalCores: null,
logicalCores: null,
memoryGiB: null,
}
}
const hardware = pickRecord(inventory, ["hardware", "Hardware"])
const vendor = pickString(hardware, ["vendor", "Vendor"])
const model = pickString(hardware, ["model", "Model"])
const serial = pickString(hardware, ["serial", "SerialNumber", "Serial"])
const cpuType = pickString(hardware, ["cpuType", "cpu", "processor", "name", "model"])
const physicalCores = pickNumber(hardware, ["physicalCores", "PhysicalCores", "cores", "Cores"])
const logicalCores = pickNumber(hardware, ["logicalCores", "LogicalCores", "threads", "Threads"])
const memoryGiB = formatBytes(pickNumber(hardware, ["memoryBytes", "MemoryBytes", "totalMemory", "TotalMemory"]))
return {
vendor,
model,
serial,
cpuType,
physicalCores,
logicalCores,
memoryGiB,
}
}
function extractPrimaryIp(inventory: Record<string, unknown> | null): string | null {
if (!inventory) return null
const network = pickRecord(inventory, ["network", "Network"])
const direct = pickString(network, ["primaryIp", "PrimaryIp", "ip", "address", "PrimaryAddress"])
if (direct) return direct
const networkArray = pickRecordArray(inventory, ["network", "Network"])
for (const entry of networkArray) {
const ip = pickString(entry, ["ip", "IP", "address", "primaryIp", "PrimaryIp"])
if (ip) return ip
}
return null
}
function extractPublicIp(inventory: Record<string, unknown> | null): string | null {
if (!inventory) return null
const network = pickRecord(inventory, ["network", "Network"])
return pickString(network, ["publicIp", "PublicIp", "externalIp", "ExternalIp"])
}
function extractGpuNames(inventory: Record<string, unknown> | null): string[] {
if (!inventory) return []
const names = new Set<string>()
const hardware = pickRecord(inventory, ["hardware", "Hardware"])
const primaryGpu = pickRecord(hardware, ["primaryGpu", "primarygpu", "PrimaryGpu"])
if (primaryGpu) {
const name = pickString(primaryGpu, ["name", "Name", "model", "Model", "GPUName", "gpuName"])
if (name) names.add(name)
}
const gpuArray = pickArray(hardware, ["gpus", "GPUs"])
if (gpuArray.length > 0) {
gpuArray.forEach((gpu) => {
const record = toRecord(gpu)
const name = pickString(record, ["name", "Name", "model", "Model", "GPUName", "gpuName"])
if (name) names.add(name)
})
}
const extended = pickRecord(inventory, ["extended", "Extended"])
const windows = pickRecord(extended, ["windows", "Windows"])
const videoControllers = pickRecordArray(windows, ["videoControllers", "VideoControllers"])
videoControllers.forEach((controller) => {
const name = pickString(controller, ["Name", "name", "Caption", "Model"])
if (name) names.add(name)
})
return Array.from(names)
}
function extractLabels(inventory: Record<string, unknown> | null): string[] {
if (!inventory) return []
const labels = inventory["labels"]
if (!labels) return []
if (Array.isArray(labels)) {
return labels
.map((label) => {
if (typeof label === "string") return label.trim()
const record = toRecord(label)
if (!record) return null
return pickString(record, ["name", "value", "label", "Name"])
})
.filter((item): item is string => Boolean(item))
}
const record = toRecord(labels)
const name = pickString(record, ["name", "label", "value"])
return name ? [name] : []
}
function summarizeLinkedUsers(users: LinkedUser[] | undefined): string | null {
if (!users || users.length === 0) return null
const parts = users.map((user) => {
const name = user.name ?? user.email ?? ""
if (user.email && user.email !== name) {
return `${name} <${user.email}>`
}
return name
})
return parts.join("; ")
}
function countSoftwareEntries(inventory: Record<string, unknown> | null): number | null {
if (!inventory) return null
const direct = inventory["software"]
if (Array.isArray(direct)) return direct.length
const extended = pickRecord(inventory, ["extended", "Extended"])
const windows = pickRecord(extended, ["windows", "Windows"])
const software = windows ? windows["software"] ?? windows["installedPrograms"] ?? windows["InstalledPrograms"] : undefined
if (Array.isArray(software)) return software.length
return null
}
function extractSoftwareEntries(hostname: string, inventory: Record<string, unknown> | null): SoftwareEntry[] {
if (!inventory) return []
const entries: SoftwareEntry[] = []
const pushEntry = (record: Record<string, unknown> | null) => {
if (!record) return
const name = pickString(record, ["DisplayName", "displayName", "Name", "name", "Title", "title"])
if (!name) return
const version = pickString(record, ["DisplayVersion", "displayVersion", "Version", "version"])
const source = pickString(record, ["ParentDisplayName", "parent", "Source", "source", "SystemComponent"])
const publisher = pickString(record, ["Publisher", "publisher", "Vendor", "vendor"])
const installed = pickString(record, ["InstalledOn", "installedOn", "InstallDate", "installDate", "InstallDateUTC"])
entries.push({
hostname,
name,
version,
source,
publisher,
installedOn: installed,
})
}
const direct = inventory["software"]
if (Array.isArray(direct)) {
direct.map(toRecord).forEach(pushEntry)
} else if (direct) {
pushEntry(toRecord(direct))
}
const extended = pickRecord(inventory, ["extended", "Extended"])
const windows = pickRecord(extended, ["windows", "Windows"])
if (windows) {
const software = windows["software"] ?? windows["installedPrograms"] ?? windows["InstalledPrograms"]
if (Array.isArray(software)) {
software.map(toRecord).forEach(pushEntry)
} else {
pushEntry(toRecord(software))
}
}
return entries
}
function extractDiskEntries(hostname: string, inventory: Record<string, unknown> | null): DiskEntry[] {
if (!inventory) return []
const entries: DiskEntry[] = []
const pushDisk = (record: Record<string, unknown> | null) => {
if (!record) return
const size = formatBytes(pickNumber(record, ["sizeBytes", "Size", "size"]))
const free = formatBytes(pickNumber(record, ["freeBytes", "FreeSpace", "free", "Free"]))
const type = pickString(record, ["type", "Type", "MediaType"])
const model = pickString(record, ["model", "Model"])
const name = pickString(record, ["name", "Name", "DeviceID", "Device"])
const mountPoint = pickString(record, ["mount", "Mount", "mountpoint", "MountPoint", "path"])
const serial = pickString(record, ["serial", "Serial", "SerialNumber"])
const smartStatus = pickString(record, ["smartStatus", "SmartStatus", "status", "Status"])
entries.push({
hostname,
type,
model,
name,
mountPoint,
size,
free,
serial,
smartStatus,
})
}
const direct = inventory["disks"]
if (Array.isArray(direct)) {
direct.map(toRecord).forEach(pushDisk)
} else {
pushDisk(toRecord(direct))
}
const extended = pickRecord(inventory, ["extended", "Extended"])
const windows = pickRecord(extended, ["windows", "Windows"])
if (windows) {
const diskDrives = windows["diskDrives"] ?? windows["DiskDrives"]
if (Array.isArray(diskDrives)) {
diskDrives.map(toRecord).forEach(pushDisk)
} else {
pushDisk(toRecord(diskDrives))
}
}
const linux = pickRecord(extended, ["linux", "Linux"])
if (linux) {
const lsblk = linux["lsblk"]
if (Array.isArray(lsblk)) {
lsblk.map(toRecord).forEach(pushDisk)
} else {
pushDisk(toRecord(lsblk))
}
}
return entries
}