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:
parent
c2050f311a
commit
479c66d52c
18 changed files with 1205 additions and 38 deletions
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue