feat(desktop-agent,admin/inventory): secure token storage via keyring; extended inventory collectors per OS; new /api/machines/inventory endpoint; posture rules + tickets; Admin UI inventory with filters, search and export; docs + CI desktop release

This commit is contained in:
Esdras Renan 2025-10-09 22:08:20 -03:00
parent c2050f311a
commit 479c66d52c
18 changed files with 1205 additions and 38 deletions

View file

@ -116,6 +116,112 @@ function mergeMetadata(current: unknown, patch: Record<string, unknown>) {
return { ...(current as Record<string, unknown>), ...patch }
}
type PostureFinding = {
kind: "CPU_HIGH" | "SERVICE_DOWN" | "SMART_FAIL"
message: string
severity: "warning" | "critical"
}
async function createTicketForAlert(
ctx: MutationCtx,
tenantId: string,
companyId: Id<"companies"> | undefined,
subject: string,
summary: string
) {
const actorEmail = process.env["MACHINE_ALERTS_TICKET_REQUESTER_EMAIL"] ?? "admin@sistema.dev"
const actor = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q: any) => q.eq("tenantId", tenantId).eq("email", actorEmail))
.unique()
if (!actor) return null
// pick first category/subcategory if not configured
const category = await ctx.db.query("ticketCategories").withIndex("by_tenant", (q: any) => q.eq("tenantId", tenantId)).first()
if (!category) return null
const subcategory = await ctx.db
.query("ticketSubcategories")
.withIndex("by_category_order", (q: any) => q.eq("categoryId", category._id))
.first()
if (!subcategory) return null
try {
const id = await (await import("./tickets"))
.create
.handler(ctx as any, {
actorId: actor._id,
tenantId,
subject,
summary,
priority: "Alta",
channel: "Automação",
queueId: undefined,
requesterId: actor._id,
assigneeId: undefined,
categoryId: category._id,
subcategoryId: subcategory._id,
customFields: undefined,
} as any)
return id
} catch (error) {
console.error("[machines.alerts] Falha ao criar ticket:", error)
return null
}
}
async function evaluatePostureAndMaybeRaise(
ctx: MutationCtx,
machine: Doc<"machines">,
args: { metrics?: any; inventory?: any; metadata?: any }
) {
const findings: PostureFinding[] = []
const metrics = args.metrics ?? (args.metadata?.metrics ?? null)
if (metrics && typeof metrics === "object") {
const usage = Number((metrics as any).cpuUsagePercent ?? (metrics as any).cpu_usage_percent)
if (Number.isFinite(usage) && usage >= 90) {
findings.push({ kind: "CPU_HIGH", message: `CPU acima de ${usage.toFixed(0)}%`, severity: "warning" })
}
}
const inventory = args.inventory ?? (args.metadata?.inventory ?? null)
if (inventory && typeof inventory === "object") {
const services = (inventory as any).services
if (Array.isArray(services)) {
const criticalDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? "").toLowerCase() !== "running")
if (criticalDown) {
findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${criticalDown.name}`, severity: "warning" })
}
}
const smart = (inventory as any).extended?.linux?.smart
if (Array.isArray(smart)) {
const failing = smart.find((e: any) => e?.smart_status && e.smart_status.passed === false)
if (failing) {
findings.push({ kind: "SMART_FAIL", message: `Disco com SMART em falha`, severity: "critical" })
}
}
}
if (!findings.length) return
const now = Date.now()
const record = {
postureAlerts: findings,
lastPostureAt: now,
}
const prevMeta = (machine.metadata && typeof machine.metadata === "object") ? (machine.metadata as Record<string, unknown>) : null
const lastAtPrev = typeof prevMeta?.lastPostureAt === "number" ? (prevMeta!.lastPostureAt as number) : 0
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, record), updatedAt: now })
if ((process.env["MACHINE_ALERTS_CREATE_TICKETS"] ?? "true").toLowerCase() !== "true") return
// Evita excesso: não cria ticket se já houve alerta nos últimos 30 minutos
if (lastAtPrev && now - lastAtPrev < 30 * 60 * 1000) return
const subject = `Alerta de máquina: ${machine.hostname}`
const summary = findings.map((f) => `${f.severity.toUpperCase()}: ${f.message}`).join(" | ")
await createTicketForAlert(ctx, machine.tenantId, machine.companyId, subject, summary)
}
export const register = mutation({
args: {
provisioningSecret: v.string(),
@ -307,6 +413,10 @@ export const upsertInventory = mutation({
})
}
// Evaluate posture/alerts based on provided metrics/inventory
const machine = (await ctx.db.get(machineId)) as Doc<"machines">
await evaluatePostureAndMaybeRaise(ctx, machine, { metrics: args.metrics, inventory: args.inventory })
return {
machineId,
tenantId,
@ -360,6 +470,10 @@ export const heartbeat = mutation({
expiresAt: now + getTokenTtlMs(),
})
// Evaluate posture/alerts & optionally create ticket
const fresh = (await ctx.db.get(machine._id)) as Doc<"machines">
await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata })
return {
ok: true,
machineId: machine._id,
@ -437,6 +551,8 @@ export const listByTenant = query({
let metrics: Record<string, unknown> | null = null
let inventory: Record<string, unknown> | null = null
let postureAlerts: Array<Record<string, unknown>> | null = null
let lastPostureAt: number | null = null
if (metadata && typeof metadata === "object") {
const metaRecord = metadata as Record<string, unknown>
@ -446,6 +562,12 @@ export const listByTenant = query({
if (metaRecord.inventory && typeof metaRecord.inventory === "object") {
inventory = metaRecord.inventory as Record<string, unknown>
}
if (Array.isArray(metaRecord.postureAlerts)) {
postureAlerts = metaRecord.postureAlerts as Array<Record<string, unknown>>
}
if (typeof metaRecord.lastPostureAt === "number") {
lastPostureAt = metaRecord.lastPostureAt as number
}
}
return {
@ -476,6 +598,8 @@ export const listByTenant = query({
: null,
metrics,
inventory,
postureAlerts,
lastPostureAt,
}
})
)