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 { 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, {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDownloadInventory}
|
onClick={handleDownloadInventoryJson}
|
||||||
className="inline-flex items-center gap-2"
|
className="inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Download className="size-4" /> Baixar JSON
|
<Download className="size-4" /> Baixar JSON
|
||||||
</Button>
|
</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
|
||||||
|
|
|
||||||
101
src/lib/xlsx.ts
101
src/lib/xlsx.ts
|
|
@ -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")
|
||||||
|
|
|
||||||
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