feat: add health dashboard and local ticket archive
This commit is contained in:
parent
0d78abbb6f
commit
0a6b808d99
15 changed files with 824 additions and 60 deletions
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
|
|
@ -29,6 +29,7 @@ import type * as liveChat from "../liveChat.js";
|
|||
import type * as machines from "../machines.js";
|
||||
import type * as metrics from "../metrics.js";
|
||||
import type * as migrations from "../migrations.js";
|
||||
import type * as ops from "../ops.js";
|
||||
import type * as queues from "../queues.js";
|
||||
import type * as rbac from "../rbac.js";
|
||||
import type * as reports from "../reports.js";
|
||||
|
|
@ -71,6 +72,7 @@ declare const fullApi: ApiFromModules<{
|
|||
machines: typeof machines;
|
||||
metrics: typeof metrics;
|
||||
migrations: typeof migrations;
|
||||
ops: typeof ops;
|
||||
queues: typeof queues;
|
||||
rbac: typeof rbac;
|
||||
reports: typeof reports;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
76
convex/ops.ts
Normal file
76
convex/ops.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { ConvexError, v } from "convex/values"
|
||||
|
||||
import { query } from "./_generated/server"
|
||||
import { getOfflineThresholdMs, getStaleThresholdMs } from "./machines"
|
||||
|
||||
const MACHINE_SCAN_LIMIT = 1200
|
||||
|
||||
export const healthSnapshot = query({
|
||||
args: {
|
||||
token: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const requiredToken = process.env["INTERNAL_HEALTH_TOKEN"] ?? process.env["REPORTS_CRON_SECRET"] ?? null
|
||||
if (requiredToken && args.token !== requiredToken) {
|
||||
throw new ConvexError("Nao autorizado")
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const offlineMs = getOfflineThresholdMs()
|
||||
const staleMs = getStaleThresholdMs(offlineMs)
|
||||
|
||||
const machines = await ctx.db.query("machines").take(MACHINE_SCAN_LIMIT)
|
||||
const heartbeats = await ctx.db.query("machineHeartbeats").collect()
|
||||
|
||||
let online = 0
|
||||
let warning = 0
|
||||
let offline = 0
|
||||
let newest = 0
|
||||
let oldest = 0
|
||||
const withHeartbeat = new Set<string>()
|
||||
|
||||
for (const hb of heartbeats) {
|
||||
const ageMs = now - hb.lastHeartbeatAt
|
||||
withHeartbeat.add(String(hb.machineId))
|
||||
if (newest === 0 || hb.lastHeartbeatAt > newest) {
|
||||
newest = hb.lastHeartbeatAt
|
||||
}
|
||||
if (oldest === 0 || hb.lastHeartbeatAt < oldest) {
|
||||
oldest = hb.lastHeartbeatAt
|
||||
}
|
||||
if (ageMs <= offlineMs) {
|
||||
online += 1
|
||||
} else if (ageMs <= staleMs) {
|
||||
warning += 1
|
||||
} else {
|
||||
offline += 1
|
||||
}
|
||||
}
|
||||
|
||||
const withoutHeartbeat = machines.length - withHeartbeat.size
|
||||
const totalOffline = offline + (withoutHeartbeat > 0 ? withoutHeartbeat : 0)
|
||||
|
||||
return {
|
||||
totals: {
|
||||
machines: machines.length,
|
||||
heartbeats: heartbeats.length,
|
||||
withoutHeartbeat: withoutHeartbeat > 0 ? withoutHeartbeat : 0,
|
||||
truncated: machines.length === MACHINE_SCAN_LIMIT,
|
||||
},
|
||||
connectivity: {
|
||||
online,
|
||||
warning,
|
||||
offline: totalOffline,
|
||||
},
|
||||
heartbeatAgeMs: {
|
||||
newest: newest ? now - newest : null,
|
||||
oldest: oldest ? now - oldest : null,
|
||||
},
|
||||
thresholds: {
|
||||
offlineMs,
|
||||
staleMs,
|
||||
},
|
||||
generatedAt: now,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -5028,3 +5028,68 @@ export const listPaginated = query({
|
|||
};
|
||||
},
|
||||
})
|
||||
|
||||
// Exporta tickets resolvidos para arquivamento externo (somente com segredo)
|
||||
export const exportForArchive = query({
|
||||
args: {
|
||||
tenantId: v.string(),
|
||||
before: v.number(), // timestamp ms
|
||||
limit: v.optional(v.number()),
|
||||
secret: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const allowedSecret = process.env["INTERNAL_HEALTH_TOKEN"] ?? process.env["REPORTS_CRON_SECRET"]
|
||||
if (allowedSecret && args.secret !== allowedSecret) {
|
||||
throw new ConvexError("Nao autorizado")
|
||||
}
|
||||
const cutoff = args.before
|
||||
const limit = Math.min(args.limit ?? 50, 200)
|
||||
const candidates = await ctx.db
|
||||
.query("tickets")
|
||||
.withIndex("by_tenant_resolved", (q) => q.eq("tenantId", args.tenantId).lt("resolvedAt", cutoff))
|
||||
.order("desc")
|
||||
.take(limit)
|
||||
|
||||
const result: Array<{
|
||||
ticket: Doc<"tickets">
|
||||
comments: Array<Doc<"ticketComments">>
|
||||
events: Array<Doc<"ticketEvents">>
|
||||
}> = []
|
||||
|
||||
for (const t of candidates) {
|
||||
const comments = await ctx.db
|
||||
.query("ticketComments")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", t._id))
|
||||
.take(200)
|
||||
|
||||
const events = await ctx.db
|
||||
.query("ticketEvents")
|
||||
.withIndex("by_ticket", (q) => q.eq("ticketId", t._id))
|
||||
.order("desc")
|
||||
.take(200)
|
||||
|
||||
result.push({
|
||||
ticket: t,
|
||||
comments,
|
||||
events,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
total: result.length,
|
||||
items: result.map((item) => ({
|
||||
ticket: item.ticket,
|
||||
comments: item.comments.map((c) => ({
|
||||
...c,
|
||||
attachments: (c.attachments ?? []).map((att) => ({
|
||||
storageId: att.storageId,
|
||||
name: att.name,
|
||||
size: att.size ?? null,
|
||||
type: att.type ?? null,
|
||||
})),
|
||||
})),
|
||||
events: item.events,
|
||||
})),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue