758 lines
26 KiB
TypeScript
758 lines
26 KiB
TypeScript
// ci: trigger convex functions deploy
|
|
import { mutation, query } from "./_generated/server"
|
|
import { api } from "./_generated/api"
|
|
import { ConvexError, v } from "convex/values"
|
|
import { sha256 } from "@noble/hashes/sha256"
|
|
import { randomBytes } from "@noble/hashes/utils"
|
|
import type { Doc, Id } from "./_generated/dataModel"
|
|
import type { MutationCtx } from "./_generated/server"
|
|
|
|
const DEFAULT_TENANT_ID = "tenant-atlas"
|
|
const DEFAULT_TOKEN_TTL_MS = 1000 * 60 * 60 * 24 * 30 // 30 dias
|
|
|
|
type NormalizedIdentifiers = {
|
|
macs: string[]
|
|
serials: string[]
|
|
}
|
|
|
|
function getProvisioningSecret() {
|
|
const secret = process.env["MACHINE_PROVISIONING_SECRET"]
|
|
if (!secret) {
|
|
throw new ConvexError("Provisionamento de máquinas não configurado")
|
|
}
|
|
return secret
|
|
}
|
|
|
|
function getTokenTtlMs(): number {
|
|
const raw = process.env["MACHINE_TOKEN_TTL_MS"]
|
|
if (!raw) return DEFAULT_TOKEN_TTL_MS
|
|
const parsed = Number(raw)
|
|
if (!Number.isFinite(parsed) || parsed < 60_000) {
|
|
return DEFAULT_TOKEN_TTL_MS
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function normalizeIdentifiers(macAddresses: string[], serialNumbers: string[]): NormalizedIdentifiers {
|
|
const normalizeMac = (value: string) => value.replace(/[^a-f0-9]/gi, "").toLowerCase()
|
|
const normalizeSerial = (value: string) => value.trim().toLowerCase()
|
|
|
|
const macs = Array.from(new Set(macAddresses.map(normalizeMac).filter(Boolean))).sort()
|
|
const serials = Array.from(new Set(serialNumbers.map(normalizeSerial).filter(Boolean))).sort()
|
|
|
|
if (macs.length === 0 && serials.length === 0) {
|
|
throw new ConvexError("Informe ao menos um identificador (MAC ou serial)")
|
|
}
|
|
|
|
return { macs, serials }
|
|
}
|
|
|
|
function toHex(input: Uint8Array) {
|
|
return Array.from(input)
|
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
.join("")
|
|
}
|
|
|
|
function computeFingerprint(tenantId: string, companySlug: string | undefined, hostname: string, ids: NormalizedIdentifiers) {
|
|
const payload = JSON.stringify({
|
|
tenantId,
|
|
companySlug: companySlug ?? null,
|
|
hostname: hostname.trim().toLowerCase(),
|
|
macs: ids.macs,
|
|
serials: ids.serials,
|
|
})
|
|
return toHex(sha256(payload))
|
|
}
|
|
|
|
function hashToken(token: string) {
|
|
return toHex(sha256(token))
|
|
}
|
|
|
|
async function ensureCompany(
|
|
ctx: MutationCtx,
|
|
tenantId: string,
|
|
companySlug?: string
|
|
): Promise<{ companyId?: Id<"companies">; companySlug?: string }> {
|
|
if (!companySlug) return {}
|
|
const company = await ctx.db
|
|
.query("companies")
|
|
.withIndex("by_tenant_slug", (q: any) => q.eq("tenantId", tenantId).eq("slug", companySlug))
|
|
.unique()
|
|
if (!company) {
|
|
throw new ConvexError("Empresa não encontrada para o tenant informado")
|
|
}
|
|
return { companyId: company._id, companySlug: company.slug }
|
|
}
|
|
|
|
async function getActiveToken(
|
|
ctx: MutationCtx,
|
|
tokenValue: string
|
|
): Promise<{ token: Doc<"machineTokens">; machine: Doc<"machines"> }> {
|
|
const tokenHash = hashToken(tokenValue)
|
|
const token = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_token_hash", (q: any) => q.eq("tokenHash", tokenHash))
|
|
.unique()
|
|
|
|
if (!token) {
|
|
throw new ConvexError("Token de máquina inválido")
|
|
}
|
|
if (token.revoked) {
|
|
throw new ConvexError("Token de máquina revogado")
|
|
}
|
|
if (token.expiresAt < Date.now()) {
|
|
throw new ConvexError("Token de máquina expirado")
|
|
}
|
|
|
|
const machine = await ctx.db.get(token.machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada para o token fornecido")
|
|
}
|
|
|
|
return { token, machine }
|
|
}
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
}
|
|
|
|
function mergeInventory(current: unknown, patch: unknown): unknown {
|
|
if (!isObject(patch)) {
|
|
return patch
|
|
}
|
|
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value === undefined) continue
|
|
if (isObject(value) && isObject(base[key])) {
|
|
base[key] = mergeInventory(base[key], value)
|
|
} else {
|
|
base[key] = value
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
function mergeMetadata(current: unknown, patch: Record<string, unknown>) {
|
|
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value === undefined) continue
|
|
if (key === "inventory") {
|
|
base[key] = mergeInventory(base[key], value)
|
|
} else if (isObject(value) && isObject(base[key])) {
|
|
base[key] = mergeInventory(base[key], value)
|
|
} else {
|
|
base[key] = value
|
|
}
|
|
}
|
|
return base
|
|
}
|
|
|
|
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 ctx.runMutation(api.tickets.create, {
|
|
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,
|
|
})
|
|
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[] = []
|
|
|
|
// Janela temporal de CPU (5 minutos)
|
|
const now = Date.now()
|
|
const metrics = args.metrics ?? (args.metadata?.metrics ?? null)
|
|
const metaObj = machine.metadata && typeof machine.metadata === "object" ? (machine.metadata as Record<string, unknown>) : {}
|
|
const prevWindow: Array<{ ts: number; usage: number }> = Array.isArray((metaObj as any).cpuWindow)
|
|
? (((metaObj as any).cpuWindow as Array<any>).map((p) => ({ ts: Number(p.ts ?? 0), usage: Number(p.usage ?? NaN) })).filter((p) => Number.isFinite(p.ts) && Number.isFinite(p.usage)))
|
|
: []
|
|
const window = prevWindow.filter((p) => now - p.ts <= 5 * 60 * 1000)
|
|
const usage = Number((metrics as any)?.cpuUsagePercent ?? (metrics as any)?.cpu_usage_percent ?? NaN)
|
|
if (Number.isFinite(usage)) {
|
|
window.push({ ts: now, usage })
|
|
}
|
|
if (window.length > 0) {
|
|
const avg = window.reduce((acc, p) => acc + p.usage, 0) / window.length
|
|
if (avg >= 90) {
|
|
findings.push({ kind: "CPU_HIGH", message: `CPU média ${avg.toFixed(0)}% em 5 min`, 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 criticalList = (process.env["MACHINE_CRITICAL_SERVICES"] ?? "")
|
|
.split(/[\s,]+/)
|
|
.map((s) => s.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
const criticalSet = new Set(criticalList)
|
|
const firstDown = services.find((s: any) => typeof s?.name === "string" && String(s.status ?? s?.Status ?? "").toLowerCase() !== "running")
|
|
if (firstDown) {
|
|
const name = String(firstDown.name ?? firstDown.Name ?? "serviço")
|
|
const sev: "warning" | "critical" = criticalSet.has(name.toLowerCase()) ? "critical" : "warning"
|
|
findings.push({ kind: "SERVICE_DOWN", message: `Serviço em falha: ${name}`, severity: sev })
|
|
}
|
|
}
|
|
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) {
|
|
const model = failing?.model_name ?? failing?.model_family ?? "Disco"
|
|
const serial = failing?.serial_number ?? failing?.device?.name ?? "—"
|
|
const temp = failing?.temperature?.current ?? failing?.temperature?.value ?? null
|
|
const details = temp ? `${model} (${serial}) · ${temp}ºC` : `${model} (${serial})`
|
|
findings.push({ kind: "SMART_FAIL", message: `SMART em falha: ${details}`, severity: "critical" })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persistir janela de CPU (limite de 120 amostras)
|
|
const cpuWindowCapped = window.slice(-120)
|
|
await ctx.db.patch(machine._id, { metadata: mergeMetadata(machine.metadata, { cpuWindow: cpuWindowCapped }) })
|
|
|
|
if (!findings.length) return
|
|
|
|
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
|
|
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(),
|
|
tenantId: v.optional(v.string()),
|
|
companySlug: v.optional(v.string()),
|
|
hostname: v.string(),
|
|
os: v.object({
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
architecture: v.optional(v.string()),
|
|
}),
|
|
macAddresses: v.array(v.string()),
|
|
serialNumbers: v.array(v.string()),
|
|
metadata: v.optional(v.any()),
|
|
registeredBy: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const secret = getProvisioningSecret()
|
|
if (args.provisioningSecret !== secret) {
|
|
throw new ConvexError("Código de provisionamento inválido")
|
|
}
|
|
|
|
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
|
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
|
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
|
|
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
|
|
const now = Date.now()
|
|
const metadataPatch = args.metadata && typeof args.metadata === "object" ? (args.metadata as Record<string, unknown>) : undefined
|
|
|
|
const existing = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
|
|
.first()
|
|
|
|
let machineId: Id<"machines">
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
tenantId,
|
|
companyId: companyId ?? existing.companyId,
|
|
companySlug: companySlug ?? existing.companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
metadata: metadataPatch ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: "online",
|
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
|
})
|
|
machineId = existing._id
|
|
} else {
|
|
machineId = await ctx.db.insert("machines", {
|
|
tenantId,
|
|
companyId,
|
|
companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
fingerprint,
|
|
metadata: metadataPatch ? mergeMetadata(undefined, metadataPatch) : undefined,
|
|
lastHeartbeatAt: now,
|
|
status: "online",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
registeredBy: args.registeredBy,
|
|
})
|
|
}
|
|
|
|
const previousTokens = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
.collect()
|
|
|
|
for (const token of previousTokens) {
|
|
if (!token.revoked) {
|
|
await ctx.db.patch(token._id, { revoked: true, lastUsedAt: now })
|
|
}
|
|
}
|
|
|
|
const tokenPlain = toHex(randomBytes(32))
|
|
const tokenHash = hashToken(tokenPlain)
|
|
const expiresAt = now + getTokenTtlMs()
|
|
|
|
await ctx.db.insert("machineTokens", {
|
|
tenantId,
|
|
machineId,
|
|
tokenHash,
|
|
expiresAt,
|
|
revoked: false,
|
|
createdAt: now,
|
|
usageCount: 0,
|
|
type: "machine",
|
|
})
|
|
|
|
return {
|
|
machineId,
|
|
tenantId,
|
|
companyId,
|
|
companySlug,
|
|
machineToken: tokenPlain,
|
|
expiresAt,
|
|
}
|
|
},
|
|
})
|
|
|
|
export const upsertInventory = mutation({
|
|
args: {
|
|
provisioningSecret: v.string(),
|
|
tenantId: v.optional(v.string()),
|
|
companySlug: v.optional(v.string()),
|
|
hostname: v.string(),
|
|
os: v.object({
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
architecture: v.optional(v.string()),
|
|
}),
|
|
macAddresses: v.array(v.string()),
|
|
serialNumbers: v.array(v.string()),
|
|
inventory: v.optional(v.any()),
|
|
metrics: v.optional(v.any()),
|
|
registeredBy: v.optional(v.string()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const secret = getProvisioningSecret()
|
|
if (args.provisioningSecret !== secret) {
|
|
throw new ConvexError("Código de provisionamento inválido")
|
|
}
|
|
|
|
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
|
const identifiers = normalizeIdentifiers(args.macAddresses, args.serialNumbers)
|
|
const fingerprint = computeFingerprint(tenantId, args.companySlug, args.hostname, identifiers)
|
|
const { companyId, companySlug } = await ensureCompany(ctx, tenantId, args.companySlug)
|
|
const now = Date.now()
|
|
|
|
const metadataPatch: Record<string, unknown> = {}
|
|
if (args.inventory && typeof args.inventory === "object") {
|
|
metadataPatch.inventory = args.inventory as Record<string, unknown>
|
|
}
|
|
if (args.metrics && typeof args.metrics === "object") {
|
|
metadataPatch.metrics = args.metrics as Record<string, unknown>
|
|
}
|
|
|
|
const existing = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant_fingerprint", (q) => q.eq("tenantId", tenantId).eq("fingerprint", fingerprint))
|
|
.first()
|
|
|
|
let machineId: Id<"machines">
|
|
|
|
if (existing) {
|
|
await ctx.db.patch(existing._id, {
|
|
tenantId,
|
|
companyId: companyId ?? existing.companyId,
|
|
companySlug: companySlug ?? existing.companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
metadata: Object.keys(metadataPatch).length ? mergeMetadata(existing.metadata, metadataPatch) : existing.metadata,
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: args.metrics ? "online" : existing.status ?? "unknown",
|
|
registeredBy: args.registeredBy ?? existing.registeredBy,
|
|
})
|
|
machineId = existing._id
|
|
} else {
|
|
machineId = await ctx.db.insert("machines", {
|
|
tenantId,
|
|
companyId,
|
|
companySlug,
|
|
hostname: args.hostname,
|
|
osName: args.os.name,
|
|
osVersion: args.os.version,
|
|
architecture: args.os.architecture,
|
|
macAddresses: identifiers.macs,
|
|
serialNumbers: identifiers.serials,
|
|
fingerprint,
|
|
metadata: Object.keys(metadataPatch).length ? mergeMetadata(undefined, metadataPatch) : undefined,
|
|
lastHeartbeatAt: now,
|
|
status: args.metrics ? "online" : "unknown",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
registeredBy: args.registeredBy,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
companyId,
|
|
companySlug,
|
|
status: args.metrics ? "online" : "unknown",
|
|
}
|
|
},
|
|
})
|
|
|
|
export const heartbeat = mutation({
|
|
args: {
|
|
machineToken: v.string(),
|
|
status: v.optional(v.string()),
|
|
hostname: v.optional(v.string()),
|
|
os: v.optional(
|
|
v.object({
|
|
name: v.string(),
|
|
version: v.optional(v.string()),
|
|
architecture: v.optional(v.string()),
|
|
})
|
|
),
|
|
metrics: v.optional(v.any()),
|
|
inventory: v.optional(v.any()),
|
|
metadata: v.optional(v.any()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { machine, token } = await getActiveToken(ctx, args.machineToken)
|
|
const now = Date.now()
|
|
|
|
const metadataPatch: Record<string, unknown> = {}
|
|
if (args.metadata && typeof args.metadata === "object") {
|
|
Object.assign(metadataPatch, args.metadata as Record<string, unknown>)
|
|
}
|
|
if (args.inventory && typeof args.inventory === "object") {
|
|
metadataPatch.inventory = mergeInventory(metadataPatch.inventory, args.inventory as Record<string, unknown>)
|
|
}
|
|
if (args.metrics && typeof args.metrics === "object") {
|
|
metadataPatch.metrics = args.metrics as Record<string, unknown>
|
|
}
|
|
const mergedMetadata = Object.keys(metadataPatch).length ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata
|
|
|
|
await ctx.db.patch(machine._id, {
|
|
hostname: args.hostname ?? machine.hostname,
|
|
osName: args.os?.name ?? machine.osName,
|
|
osVersion: args.os?.version ?? machine.osVersion,
|
|
architecture: args.os?.architecture ?? machine.architecture,
|
|
lastHeartbeatAt: now,
|
|
updatedAt: now,
|
|
status: args.status ?? "online",
|
|
metadata: mergedMetadata,
|
|
})
|
|
|
|
await ctx.db.patch(token._id, {
|
|
lastUsedAt: now,
|
|
usageCount: (token.usageCount ?? 0) + 1,
|
|
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,
|
|
expiresAt: now + getTokenTtlMs(),
|
|
}
|
|
},
|
|
})
|
|
|
|
export const resolveToken = mutation({
|
|
args: {
|
|
machineToken: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const { machine, token } = await getActiveToken(ctx, args.machineToken)
|
|
const now = Date.now()
|
|
|
|
await ctx.db.patch(token._id, {
|
|
lastUsedAt: now,
|
|
usageCount: (token.usageCount ?? 0) + 1,
|
|
})
|
|
|
|
return {
|
|
machine: {
|
|
_id: machine._id,
|
|
tenantId: machine.tenantId,
|
|
companyId: machine.companyId,
|
|
companySlug: machine.companySlug,
|
|
hostname: machine.hostname,
|
|
osName: machine.osName,
|
|
osVersion: machine.osVersion,
|
|
architecture: machine.architecture,
|
|
authUserId: machine.authUserId,
|
|
authEmail: machine.authEmail,
|
|
status: machine.status,
|
|
lastHeartbeatAt: machine.lastHeartbeatAt,
|
|
metadata: machine.metadata,
|
|
},
|
|
token: {
|
|
expiresAt: token.expiresAt,
|
|
lastUsedAt: token.lastUsedAt ?? null,
|
|
usageCount: token.usageCount ?? 0,
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
export const listByTenant = query({
|
|
args: {
|
|
tenantId: v.optional(v.string()),
|
|
includeMetadata: v.optional(v.boolean()),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const tenantId = args.tenantId ?? DEFAULT_TENANT_ID
|
|
const includeMetadata = Boolean(args.includeMetadata)
|
|
const now = Date.now()
|
|
|
|
const machines = await ctx.db
|
|
.query("machines")
|
|
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
|
|
.collect()
|
|
|
|
return Promise.all(
|
|
machines.map(async (machine) => {
|
|
const tokens = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machine._id))
|
|
.collect()
|
|
|
|
const activeToken = tokens.find((token) => !token.revoked && token.expiresAt > now) ?? null
|
|
const derivedStatus =
|
|
machine.status ??
|
|
(machine.lastHeartbeatAt && now - machine.lastHeartbeatAt <= 5 * 60 * 1000 ? "online" : machine.lastHeartbeatAt ? "offline" : "unknown")
|
|
|
|
const metadata = includeMetadata ? (machine.metadata ?? null) : null
|
|
|
|
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>
|
|
if (metaRecord.metrics && typeof metaRecord.metrics === "object") {
|
|
metrics = metaRecord.metrics as Record<string, unknown>
|
|
}
|
|
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 {
|
|
id: machine._id,
|
|
tenantId: machine.tenantId,
|
|
hostname: machine.hostname,
|
|
companyId: machine.companyId ?? null,
|
|
companySlug: machine.companySlug ?? null,
|
|
osName: machine.osName,
|
|
osVersion: machine.osVersion ?? null,
|
|
architecture: machine.architecture ?? null,
|
|
macAddresses: machine.macAddresses,
|
|
serialNumbers: machine.serialNumbers,
|
|
authUserId: machine.authUserId ?? null,
|
|
authEmail: machine.authEmail ?? null,
|
|
status: derivedStatus,
|
|
lastHeartbeatAt: machine.lastHeartbeatAt ?? null,
|
|
heartbeatAgeMs: machine.lastHeartbeatAt ? now - machine.lastHeartbeatAt : null,
|
|
registeredBy: machine.registeredBy ?? null,
|
|
createdAt: machine.createdAt,
|
|
updatedAt: machine.updatedAt,
|
|
token: activeToken
|
|
? {
|
|
expiresAt: activeToken.expiresAt,
|
|
lastUsedAt: activeToken.lastUsedAt ?? null,
|
|
usageCount: activeToken.usageCount ?? 0,
|
|
}
|
|
: null,
|
|
metrics,
|
|
inventory,
|
|
postureAlerts,
|
|
lastPostureAt,
|
|
}
|
|
})
|
|
)
|
|
},
|
|
})
|
|
|
|
export const linkAuthAccount = mutation({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
authUserId: v.string(),
|
|
authEmail: v.string(),
|
|
},
|
|
handler: async (ctx, args) => {
|
|
const machine = await ctx.db.get(args.machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
|
|
await ctx.db.patch(machine._id, {
|
|
authUserId: args.authUserId,
|
|
authEmail: args.authEmail,
|
|
updatedAt: Date.now(),
|
|
})
|
|
|
|
return { ok: true }
|
|
},
|
|
})
|
|
|
|
export const rename = mutation({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
actorId: v.id("users"),
|
|
tenantId: v.optional(v.string()),
|
|
hostname: v.string(),
|
|
},
|
|
handler: async (ctx, { machineId, actorId, tenantId, hostname }) => {
|
|
// Reutiliza requireStaff através de tickets.ts helpers
|
|
const machine = await ctx.db.get(machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
// Verifica permissão no tenant da máquina
|
|
const viewer = await ctx.db.get(actorId)
|
|
if (!viewer || viewer.tenantId !== machine.tenantId) {
|
|
throw new ConvexError("Acesso negado ao tenant da máquina")
|
|
}
|
|
const normalizedRole = (viewer.role ?? "AGENT").toUpperCase()
|
|
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
|
if (!STAFF.has(normalizedRole)) {
|
|
throw new ConvexError("Apenas equipe interna pode renomear máquinas")
|
|
}
|
|
|
|
const nextName = hostname.trim()
|
|
if (nextName.length < 2) {
|
|
throw new ConvexError("Informe um nome válido para a máquina")
|
|
}
|
|
|
|
await ctx.db.patch(machineId, { hostname: nextName, updatedAt: Date.now() })
|
|
return { ok: true }
|
|
},
|
|
})
|
|
|
|
export const remove = mutation({
|
|
args: {
|
|
machineId: v.id("machines"),
|
|
actorId: v.id("users"),
|
|
},
|
|
handler: async (ctx, { machineId, actorId }) => {
|
|
const machine = await ctx.db.get(machineId)
|
|
if (!machine) {
|
|
throw new ConvexError("Máquina não encontrada")
|
|
}
|
|
|
|
const actor = await ctx.db.get(actorId)
|
|
if (!actor || actor.tenantId !== machine.tenantId) {
|
|
throw new ConvexError("Acesso negado ao tenant da máquina")
|
|
}
|
|
const role = (actor.role ?? "AGENT").toUpperCase()
|
|
const STAFF = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
|
|
if (!STAFF.has(role)) {
|
|
throw new ConvexError("Apenas equipe interna pode excluir máquinas")
|
|
}
|
|
|
|
const tokens = await ctx.db
|
|
.query("machineTokens")
|
|
.withIndex("by_machine", (q) => q.eq("machineId", machineId))
|
|
.collect()
|
|
|
|
await Promise.all(tokens.map((token) => ctx.db.delete(token._id)))
|
|
await ctx.db.delete(machineId)
|
|
return { ok: true }
|
|
},
|
|
})
|