feat: improve machines inventory exports
This commit is contained in:
parent
d92c817e7b
commit
38b46f32ce
5 changed files with 858 additions and 222 deletions
69
src/app/api/admin/machines/[id]/inventory.xlsx/route.ts
Normal file
69
src/app/api/admin/machines/[id]/inventory.xlsx/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -2,96 +2,13 @@ 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 { buildXlsxWorkbook } from "@/lib/xlsx"
|
||||
import { buildMachinesInventoryWorkbook, type MachineInventoryRecord } from "@/server/machines/inventory-export"
|
||||
|
||||
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) {
|
||||
const session = await assertAuthenticatedSession()
|
||||
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, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
})) as MachineListEntry[]
|
||||
})) as MachineInventoryRecord[]
|
||||
|
||||
const filtered = machines.filter((machine) => {
|
||||
if (!companyId) return true
|
||||
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 key = machine.status ?? "unknown"
|
||||
acc[key] = (acc[key] ?? 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
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 workbook = buildMachinesInventoryWorkbook(filtered, {
|
||||
tenantId,
|
||||
generatedBy: session.user.name ?? session.user.email,
|
||||
companyFilterLabel,
|
||||
generatedAt: new Date(),
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
return new NextResponse(body, {
|
||||
|
|
|
|||
|
|
@ -2004,7 +2004,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
return JSON.stringify(payload, null, 2)
|
||||
}, [machine, metrics, metadata])
|
||||
const handleDownloadInventory = useCallback(() => {
|
||||
const handleDownloadInventoryJson = useCallback(() => {
|
||||
if (!machine) return
|
||||
const safeHostname = machine.hostname.replace(/[^a-z0-9_-]/gi, "-").replace(/-{2,}/g, "-").toLowerCase()
|
||||
const fileName = `${safeHostname || "machine"}_${machine.id}.json`
|
||||
|
|
@ -3915,7 +3915,14 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
) : null}
|
||||
|
||||
<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>
|
||||
<Button size="sm" variant="outline" onClick={() => setOpenDialog(true)}>Inventário completo</Button>
|
||||
</DialogTrigger>
|
||||
|
|
@ -3932,15 +3939,24 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
onChange={(e) => setDialogQuery(e.target.value)}
|
||||
className="sm:flex-1"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadInventory}
|
||||
onClick={handleDownloadInventoryJson}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<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 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
|
||||
|
|
|
|||
101
src/lib/xlsx.ts
101
src/lib/xlsx.ts
|
|
@ -6,6 +6,12 @@ export type WorksheetConfig = {
|
|||
name: string
|
||||
headers: string[]
|
||||
rows: WorksheetRow[]
|
||||
columnWidths?: Array<number | null | undefined>
|
||||
freezePane?: {
|
||||
rowSplit?: number
|
||||
columnSplit?: number
|
||||
}
|
||||
autoFilter?: boolean
|
||||
}
|
||||
|
||||
type ZipEntry = {
|
||||
|
|
@ -38,22 +44,23 @@ function columnRef(index: number): string {
|
|||
return col
|
||||
}
|
||||
|
||||
function formatCell(value: unknown, colIndex: number, rowIndex: number): string {
|
||||
const ref = `${columnRef(colIndex)}${rowIndex + 1}`
|
||||
function formatCell(value: unknown, colIndex: number, rowNumber: number, styleIndex?: number): string {
|
||||
const ref = `${columnRef(colIndex)}${rowNumber}`
|
||||
const styleAttr = styleIndex !== undefined ? ` s="${styleIndex}"` : ""
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return `<c r="${ref}"/>`
|
||||
return `<c r="${ref}"${styleAttr}/>`
|
||||
}
|
||||
|
||||
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)) {
|
||||
return `<c r="${ref}"><v>${value}</v></c>`
|
||||
return `<c r="${ref}"${styleAttr}><v>${value}</v></c>`
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -62,25 +69,75 @@ function formatCell(value: unknown, colIndex: number, rowIndex: number): string
|
|||
} else {
|
||||
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 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>`)
|
||||
|
||||
config.rows.forEach((rowData, rowIdx) => {
|
||||
const cells = config.headers.map((_, colIdx) => formatCell(rowData[colIdx], colIdx, rowIdx + 1)).join("")
|
||||
rows.push(`<row r="${rowIdx + 2}">${cells}</row>`)
|
||||
const actualRow = rowIdx + 2
|
||||
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 [
|
||||
XML_DECLARATION,
|
||||
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">',
|
||||
sheetViews,
|
||||
colsXml,
|
||||
' <sheetFormatPr defaultRowHeight="15"/>',
|
||||
" <sheetData>",
|
||||
rows.map((row) => ` ${row}`).join("\n"),
|
||||
" </sheetData>",
|
||||
autoFilter,
|
||||
"</worksheet>",
|
||||
].join("\n")
|
||||
}
|
||||
|
|
@ -206,6 +263,10 @@ export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer {
|
|||
throw new Error("Workbook requires at least one sheet")
|
||||
}
|
||||
|
||||
const styles: WorksheetStyles = {
|
||||
body: 0,
|
||||
header: 1,
|
||||
}
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString()
|
||||
const workbookRels: string[] = []
|
||||
|
|
@ -214,7 +275,7 @@ export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer {
|
|||
const sheetRefs = sheets.map((sheet, index) => {
|
||||
const sheetId = index + 1
|
||||
const relId = `rId${sheetId}`
|
||||
const worksheetXml = buildWorksheetXml(sheet)
|
||||
const worksheetXml = buildWorksheetXml(sheet, styles)
|
||||
sheetEntries.push({
|
||||
path: `xl/worksheets/sheet${sheetId}.xml`,
|
||||
data: Buffer.from(worksheetXml, "utf8"),
|
||||
|
|
@ -248,11 +309,21 @@ export function buildXlsxWorkbook(sheets: WorksheetConfig[]): Buffer {
|
|||
const stylesXml = [
|
||||
XML_DECLARATION,
|
||||
'<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>',
|
||||
' <fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>',
|
||||
' <fonts count="2">',
|
||||
' <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>',
|
||||
' <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>',
|
||||
"</styleSheet>",
|
||||
].join("\n")
|
||||
|
|
|
|||
661
src/server/machines/inventory-export.ts
Normal file
661
src/server/machines/inventory-export.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue