feat: add health dashboard and local ticket archive

This commit is contained in:
rever-tecnologia 2025-12-10 14:43:13 -03:00
parent 0d78abbb6f
commit 0a6b808d99
15 changed files with 824 additions and 60 deletions

View file

@ -251,6 +251,72 @@ function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
}
type JsonPrimitive = string | number | boolean | null
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
type JsonRecord = Record<string, JsonValue>
const MAX_JSON_DEPTH = 6
const MAX_ARRAY_LENGTH = 200
function sanitizeJsonValue(value: unknown, depth = 0): JsonValue | undefined {
if (value === null) return null
if (typeof value === "string") return value
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
if (typeof value === "boolean") return value
if (depth >= MAX_JSON_DEPTH) return undefined
if (Array.isArray(value)) {
const items: JsonValue[] = []
for (const entry of value.slice(0, MAX_ARRAY_LENGTH)) {
const sanitized = sanitizeJsonValue(entry, depth + 1)
if (sanitized !== undefined) {
items.push(sanitized)
}
}
return items
}
if (isObject(value)) {
const result: JsonRecord = {}
for (const [key, entry] of Object.entries(value)) {
const sanitized = sanitizeJsonValue(entry, depth + 1)
if (sanitized !== undefined) {
result[key] = sanitized
}
}
return result
}
return undefined
}
function sanitizeRecord(value: unknown): JsonRecord | null {
const sanitized = sanitizeJsonValue(value)
if (!sanitized || Array.isArray(sanitized)) return null
return sanitized as JsonRecord
}
function stableSerialize(value: JsonValue): string {
if (value === null) return "null"
if (typeof value !== "object") return JSON.stringify(value)
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(",")}]`
}
const entries = Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableSerialize((value as JsonRecord)[key]!)}`)
return `{${entries.join(",")}}`
}
function hashJson(value: JsonRecord | null): string | null {
if (!value) return null
const serialized = stableSerialize(value)
return toHex(sha256(utf8(serialized)))
}
function areJsonValuesEqual(a: JsonValue | undefined, b: JsonValue | undefined): boolean {
if (a === b) return true
if (a === undefined || b === undefined) return false
return stableSerialize(a) === stableSerialize(b)
}
// Busca o lastHeartbeatAt da tabela machineHeartbeats (fonte de verdade)
// Fallback para machine.lastHeartbeatAt para retrocompatibilidade durante migracao
async function getMachineLastHeartbeat(
@ -269,48 +335,50 @@ async function getMachineLastHeartbeat(
// para evitar OOM no Convex (documentos de ~100KB cada)
const INVENTORY_BLOCKLIST = new Set(["software", "extended"])
function mergeInventory(current: unknown, patch: unknown): unknown {
if (!isObject(patch)) {
return patch
function mergeInventory(current: JsonRecord | null | undefined, patch: Record<string, unknown>): JsonRecord {
const sanitizedPatch = sanitizeRecord(patch)
if (!sanitizedPatch) {
return current ? { ...current } : {}
}
const base: Record<string, unknown> = isObject(current) ? { ...(current as Record<string, unknown>) } : {}
for (const [key, value] of Object.entries(patch)) {
if (value === undefined) continue
const base: JsonRecord = current ? { ...current } : {}
for (const [key, value] of Object.entries(sanitizedPatch)) {
// Filtrar campos volumosos que causam OOM
if (INVENTORY_BLOCKLIST.has(key)) 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)
if (isObject(value) && isObject(base[key])) {
base[key] = mergeInventory(base[key] as JsonRecord, value as Record<string, unknown>)
} else {
base[key] = value
base[key] = value as JsonValue
}
}
return base
}
type JsonRecord = Record<string, unknown>
function mergeMetadata(current: unknown, patch: Record<string, unknown>): JsonRecord {
const base: JsonRecord = sanitizeRecord(current) ?? {}
const sanitizedPatch = sanitizeRecord(patch) ?? {}
for (const [key, value] of Object.entries(sanitizedPatch)) {
if (value === undefined) continue
if (key === "inventory" && isObject(value)) {
base[key] = mergeInventory(sanitizeRecord(base[key]), value as Record<string, unknown>)
} else if (isObject(value) && isObject(base[key])) {
base[key] = mergeInventory(sanitizeRecord(base[key]), value as Record<string, unknown>)
} else {
base[key] = value as JsonValue
}
}
return base
}
function ensureRecord(value: unknown): JsonRecord | null {
return isObject(value) ? (value as JsonRecord) : null
return sanitizeRecord(value)
}
function ensureRecordArray(value: unknown): JsonRecord[] {
if (!Array.isArray(value)) return []
return value.filter(isObject) as JsonRecord[]
return value
.map((entry) => sanitizeRecord(entry))
.filter((entry): entry is JsonRecord => Boolean(entry))
}
function ensureFiniteNumber(value: unknown): number | null {
@ -322,6 +390,19 @@ function ensureString(value: unknown): string | null {
return typeof value === "string" ? value : null
}
function sanitizeInventoryPayload(value: unknown): JsonRecord | null {
const record = sanitizeRecord(value)
if (!record) return null
for (const blocked of INVENTORY_BLOCKLIST) {
delete record[blocked]
}
return record
}
function sanitizeMetricsPayload(value: unknown): JsonRecord | null {
return sanitizeRecord(value)
}
function getNestedRecord(root: JsonRecord | null, ...keys: string[]): JsonRecord | null {
let current: JsonRecord | null = root
for (const key of keys) {
@ -833,9 +914,9 @@ export const heartbeat = mutation({
architecture: v.optional(v.string()),
})
),
metrics: v.optional(v.any()),
inventory: v.optional(v.any()),
metadata: v.optional(v.any()),
metrics: v.optional(v.record(v.string(), v.any())),
inventory: v.optional(v.record(v.string(), v.any())),
metadata: v.optional(v.record(v.string(), v.any())),
},
handler: async (ctx, args) => {
const { machine, token } = await getActiveToken(ctx, args.machineToken)
@ -857,41 +938,40 @@ export const heartbeat = mutation({
// 2. Preparar patch de metadata (se houver mudancas REAIS)
// IMPORTANTE: So incluimos no patch se os dados realmente mudaram
// Isso evita criar versoes desnecessarias do documento machines
const metadataPatch: Record<string, unknown> = {}
const currentMetadata = (machine.metadata ?? {}) as Record<string, unknown>
const metadataPatch: JsonRecord = {}
const currentMetadata = ensureRecord(machine.metadata) ?? {}
const incomingMeta = ensureRecord(args.metadata)
const remoteAccessSnapshot = incomingMeta ? ensureRecord(incomingMeta["remoteAccessSnapshot"]) : null
if (args.metadata && typeof args.metadata === "object") {
if (incomingMeta) {
// Filtrar apenas campos que realmente mudaram
const incomingMeta = args.metadata as Record<string, unknown>
for (const key of Object.keys(incomingMeta)) {
if (key !== "inventory" && key !== "metrics" && key !== "remoteAccessSnapshot") {
if (JSON.stringify(incomingMeta[key]) !== JSON.stringify(currentMetadata[key])) {
metadataPatch[key] = incomingMeta[key]
}
for (const [key, value] of Object.entries(incomingMeta)) {
if (key === "inventory" || key === "metrics" || key === "remoteAccessSnapshot" || key === "inventoryHash" || key === "metricsHash") {
continue
}
const currentValue = currentMetadata[key] as JsonValue | undefined
if (!areJsonValuesEqual(value as JsonValue, currentValue)) {
metadataPatch[key] = value as JsonValue
}
}
}
const remoteAccessSnapshot = (args.metadata as Record<string, unknown> | undefined)?.["remoteAccessSnapshot"]
// Inventory: so incluir se realmente mudou
if (args.inventory && typeof args.inventory === "object") {
const currentInventory = currentMetadata.inventory as Record<string, unknown> | undefined
const newInventoryStr = JSON.stringify(args.inventory)
const currentInventoryStr = JSON.stringify(currentInventory ?? {})
if (newInventoryStr !== currentInventoryStr) {
metadataPatch.inventory = mergeInventory(currentInventory, args.inventory as Record<string, unknown>)
}
const sanitizedInventory = sanitizeInventoryPayload(args.inventory)
const currentInventory = ensureRecord(currentMetadata.inventory)
const incomingInventoryHash = hashJson(sanitizedInventory)
const currentInventoryHash = typeof currentMetadata["inventoryHash"] === "string" ? currentMetadata["inventoryHash"] : null
if (sanitizedInventory && incomingInventoryHash && incomingInventoryHash !== currentInventoryHash) {
metadataPatch.inventory = mergeInventory(currentInventory, sanitizedInventory)
metadataPatch.inventoryHash = incomingInventoryHash
}
// Metrics: so incluir se realmente mudou
if (args.metrics && typeof args.metrics === "object") {
const currentMetrics = currentMetadata.metrics as Record<string, unknown> | undefined
const newMetricsStr = JSON.stringify(args.metrics)
const currentMetricsStr = JSON.stringify(currentMetrics ?? {})
if (newMetricsStr !== currentMetricsStr) {
metadataPatch.metrics = args.metrics as Record<string, unknown>
}
const sanitizedMetrics = sanitizeMetricsPayload(args.metrics)
const currentMetrics = ensureRecord(currentMetadata.metrics)
const incomingMetricsHash = hashJson(sanitizedMetrics)
const currentMetricsHash = typeof currentMetadata["metricsHash"] === "string" ? currentMetadata["metricsHash"] : null
if (sanitizedMetrics && incomingMetricsHash && incomingMetricsHash !== currentMetricsHash) {
metadataPatch.metrics = sanitizedMetrics
metadataPatch.metricsHash = incomingMetricsHash
}
// 3. Verificar se ha mudancas reais nos dados que justifiquem atualizar o documento machines
@ -902,13 +982,14 @@ export const heartbeat = mutation({
args.os.version !== machine.osVersion ||
args.os.architecture !== machine.architecture
)
const hasStatusChange = args.status && args.status !== machine.status
const hasStatusChange = typeof args.status === "string" && args.status !== machine.status
const needsMachineUpdate = hasMetadataChanges || hasHostnameChange || hasOsChange || hasStatusChange
// 4. So atualizar machines se houver mudancas reais (evita criar versoes desnecessarias)
// NOTA: lastHeartbeatAt agora vive na tabela machineHeartbeats, nao atualizamos mais aqui
if (needsMachineUpdate) {
const mergedMetadata = hasMetadataChanges ? mergeMetadata(machine.metadata, metadataPatch) : machine.metadata
const nextStatus = args.status ?? machine.status ?? (sanitizedMetrics ? "online" : "unknown")
await ctx.db.patch(machine._id, {
hostname: args.hostname ?? machine.hostname,
@ -920,7 +1001,7 @@ export const heartbeat = mutation({
deviceType: machine.deviceType ?? "desktop",
managementMode: machine.managementMode ?? "agent",
updatedAt: now,
status: args.status ?? "online",
status: nextStatus,
metadata: mergedMetadata,
})
}
@ -937,7 +1018,11 @@ export const heartbeat = mutation({
// Evaluate posture/alerts & optionally create ticket
const fresh = needsMachineUpdate ? (await ctx.db.get(machine._id)) as Doc<"machines"> : machine
await evaluatePostureAndMaybeRaise(ctx, fresh, { metrics: args.metrics, inventory: args.inventory, metadata: args.metadata })
await evaluatePostureAndMaybeRaise(ctx, fresh, {
metrics: sanitizedMetrics ?? null,
inventory: sanitizedInventory ?? null,
metadata: incomingMeta ?? null,
})
return {
ok: true,