feat: improve machines inventory exports

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

View file

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

View file

@ -2,96 +2,13 @@ import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { 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, {